2026-09-13 single-teacher.php

This commit is contained in:
Dekart Deploy Bot 2026-09-13 20:35:16 +00:00
parent 78b04cd31c
commit 1efcbb4616
27 changed files with 17162 additions and 12544 deletions

54
eslint.config.js Normal file
View File

@ -0,0 +1,54 @@
// ESLint flat config (ESLint 10.x) — dekart theme JS assets
// WordPress-контекст: скрипты темы — обычные <script>, не ES-модули.
// jQuery-обёртки, vendor-файлы и админ-скрипты обработаны отдельно.
'use strict';
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
// 1. Глобальный ignore — vendor, node_modules, сборки.
{
ignores: [
'node_modules/**',
'wp-content/themes/dekart/assets/js/owl.carousel.min.js',
'wp-content/themes/dekart/assets/vendor/**',
],
},
// 2. Базовые правила для всех JS.
js.configs.recommended,
// 3. Frontend-скрипты темы.
{
files: ['wp-content/themes/dekart/assets/js/**/*.js'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'script',
globals: {
...globals.browser,
...globals.jquery,
// Внешние скрипты: Яндекс.Карты, Метрика, Swiper.
ymaps: 'readonly',
yaCounter99835482: 'readonly',
Swiper: 'readonly',
},
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
// 4. Админ-скрипты (admin-*.js) — отдельный контекст, без jQuery-глобалов.
{
files: ['wp-content/themes/dekart/assets/js/admin-*.js'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'script',
globals: {
...globals.browser,
},
},
},
];

1007
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,5 +2,10 @@
"name": "dekart-site",
"version": "1.0.0",
"private": true,
"description": "Dekart private school WordPress theme assets"
"description": "Dekart private school WordPress theme assets",
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.10.0",
"globals": "^17.12.0"
}
}

View File

@ -14,4 +14,4 @@ parameters:
ignoreErrors:
- '#Call to an undefined method Carbon_Fields\\Field\\Field::#'
- '#Call to an undefined method Carbon_Fields\\Container\\Container::#'
- '#Function (remove_meta_box|submit_button) not found#'
- '#Function (remove_meta_box|submit_button|get_current_screen) not found#'

View File

@ -134,6 +134,11 @@ add_filter( 'plugin_action_links_memcached/object-cache.php', function ( $action
return $actions;
} );
// Админ-уведомление «Memcached / PHP-расширение memcache» СКРЫТО навсегда (2026-09-09).
// Причина: рабочее кеширование уже настроено, повторное напоминание мешает.
// Логика плагина (re-salt, сброс версии кеша, WP-CLI) не тронута.
// Вернуть уведомление: раскомментировать блок ниже.
/*
add_action( 'admin_notices', function () {
if ( ! current_user_can( 'manage_options' ) ) {
return;
@ -176,6 +181,7 @@ add_action( 'admin_notices', function () {
<strong>Не активируйте плагин</strong> он работает как drop-in.'
);
} );
*/
// ============================================================
// 6. WP-CLI

View File

@ -0,0 +1,978 @@
<?php
/*
Plugin Name: Memcached
Description: Memcached backend for the WP Object Cache.
Version: 4.0.0
Plugin URI: https://wordpress.org/plugins/memcached/
Author: Ryan Boren, Denis de Bernardy, Matt Martz, Andy Skelton
Install this file to wp-content/object-cache.php
*/
// Users with setups where multiple installs share a common wp-config.php or $table_prefix
// can use this to guarantee uniqueness for the keys generated by this object cache
if ( ! defined( 'WP_CACHE_KEY_SALT' ) ) {
define( 'WP_CACHE_KEY_SALT', '' );
}
function wp_cache_add( $key, $data, $group = '', $expire = 0 ) {
global $wp_object_cache;
return $wp_object_cache->add( $key, $data, $group, $expire );
}
function wp_cache_incr( $key, $n = 1, $group = '' ) {
global $wp_object_cache;
return $wp_object_cache->incr( $key, $n, $group );
}
function wp_cache_decr( $key, $n = 1, $group = '' ) {
global $wp_object_cache;
return $wp_object_cache->decr( $key, $n, $group );
}
function wp_cache_close() {
global $wp_object_cache;
return $wp_object_cache->close();
}
function wp_cache_delete( $key, $group = '' ) {
global $wp_object_cache;
return $wp_object_cache->delete( $key, $group );
}
function wp_cache_flush() {
global $wp_object_cache;
return $wp_object_cache->flush();
}
function wp_cache_get( $key, $group = '', $force = false, &$found = null ) {
global $wp_object_cache;
$value = apply_filters( 'pre_wp_cache_get', false, $key, $group, $force );
if ( false !== $value ) {
$found = true;
return $value;
}
return $wp_object_cache->get( $key, $group, $force, $found );
}
/**
* Retrieve multiple cache entries
*
* @param array $groups Array of arrays, of groups and keys to retrieve
* @return mixed
*/
function wp_cache_get_multi( $groups ) {
global $wp_object_cache;
return $wp_object_cache->get_multi( $groups );
}
function wp_cache_init() {
global $wp_object_cache;
$wp_object_cache = new WP_Object_Cache();
}
function wp_cache_replace( $key, $data, $group = '', $expire = 0 ) {
global $wp_object_cache;
return $wp_object_cache->replace( $key, $data, $group, $expire );
}
function wp_cache_set( $key, $data, $group = '', $expire = 0 ) {
global $wp_object_cache;
if ( defined( 'WP_INSTALLING' ) == false ) {
return $wp_object_cache->set( $key, $data, $group, $expire );
} else {
return $wp_object_cache->delete( $key, $group );
}
}
function wp_cache_switch_to_blog( $blog_id ) {
global $wp_object_cache;
return $wp_object_cache->switch_to_blog( $blog_id );
}
function wp_cache_add_global_groups( $groups ) {
global $wp_object_cache;
$wp_object_cache->add_global_groups( $groups );
}
function wp_cache_add_non_persistent_groups( $groups ) {
global $wp_object_cache;
$wp_object_cache->add_non_persistent_groups( $groups );
}
class WP_Object_Cache {
var $global_groups = array( 'WP_Object_Cache_global' );
var $no_mc_groups = array();
var $cache = array();
var $mc = array();
var $default_mcs = array();
var $stats = array();
var $group_ops = array();
var $flush_group = 'WP_Object_Cache';
var $global_flush_group = 'WP_Object_Cache_global';
var $flush_key = "flush_number_v4";
var $old_flush_key = "flush_number";
var $flush_number = array();
var $global_flush_number = null;
var $cache_enabled = true;
var $default_expiration = 0;
var $max_expiration = 2592000; // 30 days
var $stats_callback = null;
var $connection_errors = array();
var $time_total = 0;
var $size_total = 0;
var $slow_op_microseconds = 0.005; // 5 ms
function add( $id, $data, $group = 'default', $expire = 0 ) {
$key = $this->key( $id, $group );
if ( is_object( $data ) ) {
$data = clone $data;
}
if ( in_array( $group, $this->no_mc_groups ) ) {
$this->cache[ $key ] = [
'value' => $data,
'found' => false,
];
return true;
} elseif ( isset( $this->cache[ $key ][ 'value' ] ) && false !== $this->cache[ $key ][ 'value' ] ) {
return false;
}
$mc =& $this->get_mc( $group );
$expire = intval( $expire );
if ( 0 === $expire || $expire > $this->max_expiration ) {
$expire = $this->default_expiration;
}
$size = $this->get_data_size( $data );
$this->timer_start();
$result = $mc->add( $key, $data, false, $expire );
$elapsed = $this->timer_stop();
$comment = '';
if ( isset( $this->cache[ $key ] ) ) {
$comment .= ' [lc already]';
}
if ( false === $result ) {
$comment .= ' [mc already]';
}
$this->group_ops_stats( 'add', $key, $group, $size, $elapsed, $comment );
if ( false !== $result ) {
$this->cache[ $key ] = [
'value' => $data,
'found' => true,
];
} else if ( false === $result && true === isset( $this->cache[$key][ 'value' ] ) && false === $this->cache[$key][ 'value' ] ) {
/*
* Here we unset local cache if remote add failed and local cache value is equal to `false` in order
* to update the local cache anytime we get a new information from remote server. This way, the next
* cache get will go to remote server and will fetch recent data.
*/
unset( $this->cache[$key] );
}
return $result;
}
function add_global_groups( $groups ) {
if ( ! is_array( $groups ) ) {
$groups = (array) $groups;
}
$this->global_groups = array_merge( $this->global_groups, $groups );
$this->global_groups = array_unique( $this->global_groups );
}
function add_non_persistent_groups( $groups ) {
if ( ! is_array( $groups ) ) {
$groups = (array) $groups;
}
$this->no_mc_groups = array_merge( $this->no_mc_groups, $groups );
$this->no_mc_groups = array_unique( $this->no_mc_groups );
}
function incr( $id, $n = 1, $group = 'default' ) {
$key = $this->key( $id, $group );
$mc =& $this->get_mc( $group );
$incremented = $mc->increment( $key, $n );
$this->cache[ $key ] = [
'value' => $incremented,
'found' => false !== $incremented,
];
return $this->cache[ $key ][ 'value' ];
}
function decr( $id, $n = 1, $group = 'default' ) {
$key = $this->key( $id, $group );
$mc =& $this->get_mc( $group );
$decremented = $mc->decrement( $key, $n );
$this->cache[ $key ] = [
'value' => $decremented,
'found' => false !== $decremented,
];
return $this->cache[ $key ][ 'value' ];
}
function close() {
foreach ( $this->mc as $bucket => $mc ) {
$mc->close();
}
}
function delete( $id, $group = 'default' ) {
$key = $this->key( $id, $group );
if ( in_array( $group, $this->no_mc_groups ) ) {
unset( $this->cache[ $key ] );
return true;
}
$mc =& $this->get_mc( $group );
$this->timer_start();
$result = $mc->delete( $key );
$elapsed = $this->timer_stop();
$this->group_ops_stats( 'delete', $key, $group, null, $elapsed );
if ( false !== $result ) {
unset( $this->cache[ $key ] );
}
return $result;
}
// Gets number from all default servers, replicating if needed
function get_max_flush_number( $group ) {
$key = $this->key( $this->flush_key, $group );
$values = array();
$size = 19; // size of microsecond timestamp serialized
foreach ( $this->default_mcs as $i => $mc ) {
$flags = false;
$this->timer_start();
$values[ $i ] = $mc->get( $key, $flags );
$elapsed = $this->timer_stop();
if ( empty( $values[ $i ] ) ) {
$this->group_ops_stats( 'get_flush_number', $key, $group, null, $elapsed, 'not_in_memcache' );
} else {
$this->group_ops_stats( 'get_flush_number', $key, $group, $size, $elapsed, 'memcache' );
}
}
$max = max( $values );
if ( ! $max > 0 ) {
return false;
}
// Replicate to servers not having the max.
$expire = 0;
foreach ( $this->default_mcs as $i => $mc ) {
if ( $values[ $i ] < $max ) {
$this->timer_start();
$mc->set( $key, $max, false, $expire );
$elapsed = $this->timer_stop();
$this->group_ops_stats( 'set_flush_number', $key, $group, $size, $elapsed, 'replication_repair' );
}
}
return $max;
}
function set_flush_number( $value, $group ) {
$key = $this->key( $this->flush_key, $group );
$expire = 0;
$size = 19;
foreach ( $this->default_mcs as $i => $mc ) {
$this->timer_start();
$mc->set( $key, $value, false, $expire );
$elapsed = $this->timer_stop();
$this->group_ops_stats( 'set_flush_number', $key, $group, $size, $elapsed, 'replication' );
}
}
function get_flush_number( $group ) {
$flush_number = $this->get_max_flush_number( $group );
// What if there is no v4 flush number?
if ( empty( $flush_number ) ) {
// Look for the v3 flush number key.
$flush_number = intval( $this->get( $this->old_flush_key, $group ) );
if ( !empty( $flush_number ) ) {
// Found v3 flush number. Upgrade to v4 with replication.
$this->set_flush_number( $flush_number, $group );
// Delete v3 key so we can't later restore it and find stale keys.
} else {
// No flush number found anywhere. Make a new one. This flushes the cache.
$flush_number = $this->new_flush_number();
$this->set_flush_number( $flush_number, $group );
}
}
return $flush_number;
}
function get_global_flush_number() {
if ( ! isset( $this->global_flush_number ) ) {
$this->global_flush_number = $this->get_flush_number( $this->global_flush_group );
}
return $this->global_flush_number;
}
function get_blog_flush_number() {
if ( ! isset( $this->flush_number[ $this->blog_prefix ] ) ) {
$this->flush_number[ $this->blog_prefix ] = $this->get_flush_number( $this->flush_group );
}
return $this->flush_number[ $this->blog_prefix ];
}
function flush() {
// Do not use the memcached flush method. It acts on an
// entire memcached server, affecting all sites.
// Flush is also unusable in some setups, e.g. twemproxy.
// Instead, rotate the key prefix for the current site.
// Global keys are rotated when flushing on any network's
// main site.
$this->cache = array();
$flush_number = $this->new_flush_number();
$this->rotate_site_keys( $flush_number );
if ( is_main_site() ) {
$this->rotate_global_keys( $flush_number );
}
}
function rotate_site_keys( $flush_number = null ) {
if ( is_null( $flush_number ) ) {
$flush_number = $this->new_flush_number();
}
$this->set_flush_number( $flush_number, $this->flush_group );
$this->flush_number[ $this->blog_prefix ] = $flush_number;
}
function rotate_global_keys( $flush_number = null ) {
if ( is_null( $flush_number ) ) {
$flush_number = $this->new_flush_number();
}
$this->set_flush_number( $flush_number, $this->global_flush_group );
$this->global_flush_number = $flush_number;
}
function new_flush_number() {
return intval( microtime( true ) * 1e6 );
}
function get( $id, $group = 'default', $force = false, &$found = null ) {
$key = $this->key( $id, $group );
$mc =& $this->get_mc( $group );
$found = true;
if ( isset( $this->cache[ $key ] ) && ( ! $force || in_array( $group, $this->no_mc_groups ) ) ) {
if ( isset( $this->cache[ $key ][ 'value' ] ) && is_object( $this->cache[ $key ][ 'value' ] ) ) {
$value = clone $this->cache[ $key ][ 'value' ];
} else {
$value = $this->cache[ $key ][ 'value' ];
}
$found = $this->cache[ $key ][ 'found' ];
$this->group_ops_stats( 'get_local', $key, $group, null, null, 'local' );
} else if ( in_array( $group, $this->no_mc_groups ) ) {
$this->cache[ $key ] = [
'value' => $value = false,
'found' => false,
];
$found = false;
$this->group_ops_stats( 'get_local', $key, $group, null, null, 'not_in_local' );
} else {
$flags = false;
$this->timer_start();
$value = $mc->get( $key, $flags );
$elapsed = $this->timer_stop();
// Value will be unchanged if the key doesn't exist.
if ( false === $flags ) {
$found = false;
$value = false;
}
$this->cache[ $key ] = [
'value' => $value,
'found' => $found,
];
if ( is_null( $value ) || $value === false ) {
$this->group_ops_stats( 'get', $key, $group, null, $elapsed, 'not_in_memcache' );
} else if ( 'checkthedatabaseplease' === $value ) {
$this->group_ops_stats( 'get', $key, $group, null, $elapsed, 'checkthedatabaseplease' );
} else {
$size = $this->get_data_size( $value );
$this->group_ops_stats( 'get', $key, $group, $size, $elapsed, 'memcache' );
}
}
if ( 'checkthedatabaseplease' === $value ) {
unset( $this->cache[ $key ] );
$found = false;
$value = false;
}
return $value;
}
function get_multi( $groups ) {
/*
format: $get['group-name'] = array( 'key1', 'key2' );
*/
$return = array();
$return_cache = array(
'value' => false,
'found' => false,
);
foreach ( $groups as $group => $ids ) {
$mc =& $this->get_mc( $group );
$keys = array();
$this->timer_start();
foreach ( $ids as $id ) {
$key = $this->key( $id, $group );
$keys[] = $key;
if ( isset( $this->cache[ $key ] ) ) {
if ( is_object( $this->cache[ $key ][ 'value'] ) ) {
$return[ $key ] = clone $this->cache[ $key ][ 'value'];
$return_cache[ $key ] = [
'value' => clone $this->cache[ $key ][ 'value'],
'found' => $this->cache[ $key ][ 'found'],
];
} else {
$return[ $key ] = $this->cache[ $key ][ 'value'];
$return_cache[ $key ] = [
'value' => $this->cache[ $key ][ 'value' ],
'found' => $this->cache[ $key ][ 'found' ],
];
}
continue;
} else if ( in_array( $group, $this->no_mc_groups ) ) {
$return[ $key ] = false;
$return_cache[ $key ] = [
'value' => false,
'found' => false,
];
continue;
} else {
$fresh_get = $mc->get( $key );
$return[ $key ] = $fresh_get;
$return_cache[ $key ] = [
'value' => $fresh_get,
'found' => false !== $fresh_get,
];
}
}
$elapsed = $this->timer_stop();
$this->group_ops_stats( 'get_multi', $keys, $group, null, $elapsed );
}
$this->cache = array_merge( $this->cache, $return_cache );
return $return;
}
function flush_prefix( $group ) {
if ( $group === $this->flush_group || $group === $this->global_flush_group ) {
// Never flush the flush numbers.
$number = '_';
} elseif ( false !== array_search( $group, $this->global_groups ) ) {
$number = $this->get_global_flush_number();
} else {
$number = $this->get_blog_flush_number();
}
return $number . ':';
}
function key( $key, $group ) {
if ( empty( $group ) ) {
$group = 'default';
}
$prefix = $this->key_salt;
$prefix .= $this->flush_prefix( $group );
if ( false !== array_search( $group, $this->global_groups ) ) {
$prefix .= $this->global_prefix;
} else {
$prefix .= $this->blog_prefix;
}
return preg_replace( '/\s+/', '', "$prefix:$group:$key" );
}
function replace( $id, $data, $group = 'default', $expire = 0 ) {
$key = $this->key( $id, $group );
$expire = intval( $expire );
if ( 0 === $expire || $expire > $this->max_expiration ) {
$expire = $this->default_expiration;
}
$mc =& $this->get_mc( $group );
if ( is_object( $data ) ) {
$data = clone $data;
}
$size = $this->get_data_size( $data );
$this->timer_start();
$result = $mc->replace( $key, $data, false, $expire );
$elapsed = $this->timer_stop();
$this->group_ops_stats( 'replace', $key, $group, $size, $elapsed );
if ( false !== $result ) {
$this->cache[ $key ] = [
'value' => $data,
'found' => true,
];
}
return $result;
}
function set( $id, $data, $group = 'default', $expire = 0 ) {
$key = $this->key( $id, $group );
if ( isset( $this->cache[ $key ] ) && ( 'checkthedatabaseplease' === $this->cache[ $key ][ 'value' ] ) ) {
return false;
}
if ( is_object( $data ) ) {
$data = clone $data;
}
$this->cache[ $key ] = [
'value' => $data,
'found' => false, // Set to false as not technically found in memcache at this point.
];
if ( in_array( $group, $this->no_mc_groups ) ) {
$this->group_ops_stats( 'set_local', $key, $group, null, null );
return true;
}
$expire = intval( $expire );
if ( 0 === $expire || $expire > $this->max_expiration ) {
$expire = $this->default_expiration;
}
$mc =& $this->get_mc( $group );
$size = $this->get_data_size( $data );
$this->timer_start();
$result = $mc->set( $key, $data, false, $expire );
$elapsed = $this->timer_stop();
$this->group_ops_stats( 'set', $key, $group, $size, $elapsed );
// Update the found cache value with the result of the set in memcache.
$this->cache[ $key ][ 'found' ] = $result;
return $result;
}
function switch_to_blog( $blog_id ) {
global $table_prefix;
$blog_id = (int) $blog_id;
$this->blog_prefix = ( is_multisite() ? $blog_id : $table_prefix );
}
function colorize_debug_line( $line, $trailing_html = '' ) {
$colors = array(
'get' => 'green',
'get_local' => 'lightgreen',
'get_multi' => 'fuchsia',
'set' => 'purple',
'set_local' => 'orchid',
'add' => 'blue',
'delete' => 'red',
'delete_local' => 'tomato',
'slow-ops' => 'crimson',
);
$cmd = substr( $line, 0, strpos( $line, ' ' ) );
// Start off with a neutral default color...
$color_for_cmd = 'brown';
// And if the cmd has a specific color, use that instead
if ( isset( $colors[ $cmd ] ) ) {
$color_for_cmd = $colors[ $cmd ];
}
$cmd2 = "<span style='color:" . esc_attr( $color_for_cmd ) . "; font-weight: bold;'>" . esc_html( $cmd ) . "</span>";
return $cmd2 . esc_html( substr( $line, strlen( $cmd ) ) ) . "$trailing_html\n";
}
function js_toggle() {
echo "
<script>
function memcachedToggleVisibility( id, hidePrefix ) {
var element = document.getElementById( id );
if ( ! element ) {
return;
}
// Hide all element with `hidePrefix` if given. Used to display only one element at a time.
if ( hidePrefix ) {
var groupStats = document.querySelectorAll( '[id^=\"' + hidePrefix + '\"]' );
groupStats.forEach(
function ( element ) {
element.style.display = 'none';
}
);
}
// Toggle the one we clicked.
if ( 'none' === element.style.display ) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
}
</script>
";
}
function stats() {
$this->js_toggle();
echo '<h2><span>Total memcache query time:</span>' . number_format_i18n( sprintf( '%0.1f', $this->time_total * 1000 ), 1 ) . ' ms</h2>';
echo "\n";
echo '<h2><span>Total memcache size:</span>' . esc_html( size_format( $this->size_total, 2 ) ) . '</h2>';
echo "\n";
foreach ( $this->stats as $stat => $n ) {
if ( empty( $n ) ) {
continue;
}
echo '<h2>';
echo $this->colorize_debug_line( "$stat $n" );
echo '</h2>';
}
echo "<ul class='debug-menu-links' style='clear:left;font-size:14px;'>\n";
$groups = array_keys( $this->group_ops );
usort( $groups, 'strnatcasecmp' );
$active_group = $groups[0];
// Always show `slow-ops` first
if ( in_array( 'slow-ops', $groups ) ) {
$slow_ops_key = array_search( 'slow-ops', $groups );
$slow_ops = $groups[ $slow_ops_key ];
unset( $groups[ $slow_ops_key ] );
array_unshift( $groups, $slow_ops );
$active_group = 'slow-ops';
}
$total_ops = 0;
$group_titles = array();
foreach ( $groups as $group ) {
$group_name = $group;
if ( empty( $group_name ) ) {
$group_name = 'default';
}
$group_ops = count( $this->group_ops[ $group ] );
$group_size = size_format( array_sum( array_map( function ( $op ) { return $op[2]; }, $this->group_ops[ $group ] ) ), 2 );
$group_time = number_format_i18n( sprintf( '%0.1f', array_sum( array_map( function ( $op ) { return $op[3]; }, $this->group_ops[ $group ] ) ) * 1000 ), 1 );
$total_ops += $group_ops;
$group_title = "{$group_name} [$group_ops][$group_size][{$group_time} ms]";
$group_titles[ $group ] = $group_title;
echo "\t<li><a href='#' onclick='memcachedToggleVisibility( \"object-cache-stats-menu-target-" . esc_js( $group_name ) . "\", \"object-cache-stats-menu-target-\" );'>" . esc_html( $group_title ) . "</a></li>\n";
}
echo "</ul>\n";
echo "<div id='object-cache-stats-menu-targets'>\n";
foreach ( $groups as $group ) {
$group_name = $group;
if ( empty( $group_name ) ) {
$group_name = 'default';
}
$current = $active_group == $group ? 'style="display: block"' : 'style="display: none"';
echo "<div id='object-cache-stats-menu-target-" . esc_attr( $group_name ) . "' class='object-cache-stats-menu-target' $current>\n";
echo '<h3>' . esc_html( $group_titles[ $group ] ) . '</h3>' . "\n";
echo "<pre>\n";
foreach ( $this->group_ops[ $group ] as $index => $arr ) {
printf( '%3d ', $index );
echo $this->get_group_ops_line( $index, $arr );
}
echo "</pre>\n";
echo "</div>";
}
echo "</div>";
}
function get_group_ops_line( $index, $arr ) {
// operation
$line = "{$arr[0]} ";
// key
$json_encoded_key = json_encode( $arr[1] );
$line .= $json_encoded_key . " ";
// comment
if ( ! empty( $arr[4] ) ) {
$line .= "{$arr[4]} ";
}
// size
if ( isset( $arr[2] ) ) {
$line .= '(' . size_format( $arr[2], 2 ) . ') ';
}
// time
if ( isset( $arr[3] ) ) {
$line .= '(' . number_format_i18n( sprintf( '%0.1f', $arr[3] * 1000 ), 1 ) . ' ms)';
}
// backtrace
$bt_link = '';
if ( isset( $arr[6] ) ) {
$key_hash = md5( $index . $json_encoded_key );
$bt_link = " <small><a href='#' onclick='memcachedToggleVisibility( \"object-cache-stats-debug-$key_hash\" );'>Toggle Backtrace</a></small>";
$bt_link .= "<pre id='object-cache-stats-debug-$key_hash' style='display:none'>" . esc_html( $arr[6] ) . "</pre>";
}
return $this->colorize_debug_line( $line, $bt_link );
}
function &get_mc( $group ) {
if ( isset( $this->mc[ $group ] ) ) {
return $this->mc[ $group ];
}
return $this->mc['default'];
}
function failure_callback( $host, $port ) {
$this->connection_errors[] = array(
'host' => $host,
'port' => $port,
);
}
function salt_keys( $key_salt ) {
if ( strlen( $key_salt ) ) {
$this->key_salt = $key_salt . ':';
} else {
$this->key_salt = '';
}
}
function __construct() {
$this->stats = array(
'get' => 0,
'get_local' => 0,
'get_multi' => 0,
'set' => 0,
'set_local' => 0,
'add' => 0,
'delete' => 0,
'delete_local' => 0,
'slow-ops' => 0,
);
global $memcached_servers;
if ( isset( $memcached_servers ) ) {
$buckets = $memcached_servers;
} else {
$buckets = array( '127.0.0.1:11211' );
}
reset( $buckets );
if ( is_int( key( $buckets ) ) ) {
$buckets = array( 'default' => $buckets );
}
foreach ( $buckets as $bucket => $servers ) {
$this->mc[ $bucket ] = new Memcache();
foreach ( $servers as $i => $server ) {
if ( 'unix://' == substr( $server, 0, 7 ) ) {
$node = $server;
$port = 0;
} else {
list ( $node, $port ) = explode( ':', $server );
if ( ! $port ) {
$port = ini_get( 'memcache.default_port' );
}
$port = intval( $port );
if ( ! $port ) {
$port = 11211;
}
}
$this->mc[ $bucket ]->addServer( $node, $port, true, 1, 1, 15, true, array( $this, 'failure_callback' ) );
$this->mc[ $bucket ]->setCompressThreshold( 20000, 0.2 );
// Prepare individual connections to servers in default bucket for flush_number redundancy
if ( 'default' === $bucket ) {
$this->default_mcs[ $i ] = new Memcache();
$this->default_mcs[ $i ]->addServer( $node, $port, true, 1, 1, 15, true, array( $this, 'failure_callback' ) );
}
}
}
global $blog_id, $table_prefix;
$this->global_prefix = '';
$this->blog_prefix = '';
if ( function_exists( 'is_multisite' ) ) {
$this->global_prefix = ( is_multisite() || defined( 'CUSTOM_USER_TABLE' ) && defined( 'CUSTOM_USER_META_TABLE' ) ) ? '' : $table_prefix;
$this->blog_prefix = ( is_multisite() ? $blog_id : $table_prefix );
}
$this->salt_keys( WP_CACHE_KEY_SALT );
$this->cache_hits =& $this->stats['get'];
$this->cache_misses =& $this->stats['add'];
}
function increment_stat( $field, $num = 1 ) {
if ( ! isset( $this->stats[ $field ] ) ) {
$this->stats[ $field ] = $num;
} else {
$this->stats[ $field ] += $num;
}
}
function group_ops_stats( $op, $keys, $group, $size, $time, $comment = '' ) {
$this->increment_stat( $op );
// we have no use of the local ops details for now
if ( strpos( $op, '_local' ) ) {
return;
}
$this->size_total += $size;
$keys = $this->strip_memcached_keys( $keys );
if ( $time > $this->slow_op_microseconds && 'get_multi' !== $op ) {
$this->increment_stat( 'slow-ops' );
$backtrace = null;
if ( function_exists( 'wp_debug_backtrace_summary' ) ) {
$backtrace = wp_debug_backtrace_summary();
}
$this->group_ops['slow-ops'][] = array( $op, $keys, $size, $time, $comment, $group, $backtrace );
}
$this->group_ops[ $group ][] = array( $op, $keys, $size, $time, $comment );
}
/**
* Key format: key_salt:flush_number:table_prefix:key_name
*
* We want to strip the `key_salt:flush_number` part to not leak the memcached keys.
* If `key_salt` is set we strip `'key_salt:flush_number`, otherwise just strip the `flush_number` part.
*/
function strip_memcached_keys( $keys ) {
if ( ! is_array( $keys ) ) {
$keys = [ $keys ];
}
foreach ( $keys as $key => $value ) {
$offset = 0;
if ( ! empty( $this->key_salt ) ) {
$offset = strpos( $value, ':' ) + 1;
}
$start = strpos( $value, ':', $offset );
$keys[ $key ] = substr( $value, $start + 1 );
}
if ( 1 === count( $keys ) ) {
return $keys[0];
}
return $keys;
}
function timer_start() {
$this->time_start = microtime( true );
return true;
}
function timer_stop() {
$time_total = microtime( true ) - $this->time_start;
$this->time_total += $time_total;
return $time_total;
}
function get_data_size( $data ) {
if ( is_string( $data ) ) {
return strlen( $data );
}
$serialized = serialize( $data );
return strlen( $serialized );
}
}

View File

@ -0,0 +1,120 @@
/*
* Админ: Настройки филиала вертикальные вкладки
* (колонка слева, контент справа; тёмная тема CF сохранена)
* */
.dekart-branch-tabs {
display: flex;
align-items: flex-start;
gap: 20px;
max-width: 1100px;
margin-top: 4px;
}
.dekart-branch-tabs__nav {
flex: 0 0 260px;
position: sticky;
top: 46px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--cf-border, #c3c4c7);
border-radius: 8px;
padding: 8px;
box-sizing: border-box;
}
.dekart-branch-tabs__nav ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.dekart-branch-tabs__nav button {
display: block;
width: 100%;
text-align: left;
padding: 9px 12px;
font-size: 13px;
line-height: 1.3;
font-weight: 600;
color: var(--cf-text-muted, #646970);
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
font-family: inherit;
}
.dekart-branch-tabs__nav button:hover {
background: rgba(0, 0, 0, 0.08);
color: var(--cf-text, #1d2327);
}
body.branch-settings_page_branch-settings .dekart-branch-tabs__nav button:hover,
body.toplevel_page_branch-settings .dekart-branch-tabs__nav button:hover {
background: rgba(255, 255, 255, 0.1);
}
.dekart-branch-tabs__nav button.is-active {
background: var(--wp-admin-theme-color, #2271b1);
color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.dekart-branch-tabs__content {
flex: 1 1 auto;
min-width: 0;
}
/* Панели-секции: одна видна, остальные скрыты. */
.dekart-branch-tabs__content .branch-section {
display: none;
max-width: none;
}
.dekart-branch-tabs__content .branch-section.is-active {
display: block;
}
/* Внутри панели заголовок-аккордеон не нужен — он стал вкладкой слева. */
.dekart-branch-tabs__content .branch-section h2 {
display: none;
}
/* ГЕО (первая секция): 14 полей — в 2 столбца, чтобы блок был компактным. */
.dekart-branch-tabs__content .branch-section:first-child .branch-section-body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
align-items: start;
}
.dekart-branch-tabs__content .branch-section:first-child .branch-field:last-child {
grid-column: 1 / -1;
}
/* Узкие экраны: колонка сверху и сворачивается в список. */
@media (max-width: 782px) {
.dekart-branch-tabs {
flex-direction: column;
gap: 12px;
}
.dekart-branch-tabs__nav {
flex: none;
position: static;
width: 100%;
}
.dekart-branch-tabs__nav ul {
flex-direction: row;
flex-wrap: wrap;
}
.dekart-branch-tabs__nav button {
width: auto;
white-space: nowrap;
}
/* ГЕО на мобильной админке — снова один столбец. */
.dekart-branch-tabs__content .branch-section:first-child .branch-section-body {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,36 @@
/*
* Админ: вертикальные вкладки CF (преподаватель)
* Только экран редактирования teacher. Нативный layout
* CF 'tabbed-vertical' (колонка 300px слева) + мелкие правки.
* */
body.post-type-teacher .cf-container__tabs--tabbed-vertical .cf-container__tabs-list .cf-container__tabs-item--current {
background: #fff;
}
body.post-type-teacher .cf-container__tabs--tabbed-vertical .cf-container__tabs-list .cf-container__tabs-item--current button {
font-weight: 700;
color: #1d2327;
box-shadow: inset 3px 0 0 var(--wp-admin-theme-color, #2271b1);
}
body.post-type-teacher .cf-container__fields {
padding: 16px 18px 20px;
}
body.post-type-teacher.dekart-cf-tabs-broken .postbox {
outline: 2px dashed #d63638;
outline-offset: -6px;
}
/* На узких экранах (мобильная админка) колонка не помещается — стек. */
@media (max-width: 782px) {
body.post-type-teacher .cf-container--tabbed-vertical {
flex-direction: column;
}
body.post-type-teacher .cf-container__tabs--tabbed-vertical {
width: 100%;
border-right: 0;
border-bottom: 1px solid #e2e4e7;
}
}

View File

@ -46,9 +46,6 @@
.stories {
background-image: radial-gradient(circle 180px at 0% 30%, rgba(37,99,235,0.05) 0%, transparent 70%);
}
.reviews-front-section {
background-image: radial-gradient(circle 250px at 80% 0%, rgba(37,99,235,0.07) 0%, transparent 70%);
}
.contacts {
background-image: radial-gradient(circle 200px at 50% 100%, rgba(37,99,235,0.05) 0%, transparent 70%);
}
@ -965,182 +962,6 @@
font-size: 11px;
}
/* ---- Секция отзывов на главной (reviews-front) ---- */
.reviews-front-section {
position: relative;
padding: 48px 0;
}
.reviews-front-section .section-header--centered {
margin-bottom: 40px;
}
.reviews-front__grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 32px;
}
.reviews-front__card {
width: 100%;
background: linear-gradient(90deg, #BF568E, #006DB7);
border-radius: 21px;
padding: 2px;
}
.reviews-front__card-inner {
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 12px;
border-radius: 19px;
background: #FFFFFF;
height: 100%;
}
@media (min-width: 768px) {
.reviews-front__card {
width: calc(50% - 10px);
}
.reviews-front__card:last-child:nth-child(odd) {
margin: 0 auto;
}
}
@media (min-width: 1024px) {
.reviews-front__card {
width: calc(33.33% - 14px);
}
}
.reviews-front__header {
display: flex;
align-items: center;
gap: 12px;
}
.reviews-front__avatar {
width: 57px;
height: 57px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
font-size: 1.375rem;
font-weight: 700;
color: #fff;
}
.reviews-front__avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.reviews-front__avatar-letter {
line-height: 1;
}
.reviews-front__client-data {
flex: 1;
}
.reviews-front__name {
font-weight: 600;
font-size: 1rem;
color: var(--color-text, #0F172A);
}
.reviews-front__stars {
display: flex;
align-items: center;
gap: 4px;
}
.reviews-front__stars svg {
display: block;
}
.reviews-front__date {
font-size: 0.8125rem;
color: var(--color-text-light, #64748B);
margin-left: auto;
}
.reviews-front__teacher {
font-size: 0.875rem;
color: var(--color-text-muted, #475569);
margin: 0;
}
.reviews-front__teacher span {
font-weight: 600;
color: var(--color-text, #0F172A);
}
.reviews-front__text {
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-text-muted, #475569);
flex: 1;
}
.reviews-front__badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.75rem;
font-weight: 600;
color: #059669;
margin-top: 4px;
}
.reviews-front__badge svg {
flex-shrink: 0;
}
.reviews-front__all-link {
text-align: center;
}
.reviews-front__scroll-hint {
display: none;
font-size: 0.8125rem;
color: var(--color-text-muted, #475569);
text-align: right;
margin-bottom: 8px;
animation: comparison-fade 3s ease-out forwards;
animation-delay: 1s;
opacity: 0;
}
.ds-hero__rating-text {
font-size: 12px;
line-height: 1.3;
}
.ds-hero__rating-badge {
flex-wrap: wrap;
gap: 4px;
}
@media (max-width: 768px) {
.reviews-front-section {
padding: 32px 0;
}
.reviews-front__scroll-hint {
display: block;
}
.reviews-front__wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 2px;
scrollbar-width: thin;
scrollbar-color: var(--color-primary, #2563EB) var(--color-border, #E2E8F0);
}
.reviews-front__wrap::-webkit-scrollbar {
height: 4px;
}
.reviews-front__wrap::-webkit-scrollbar-track {
background: var(--color-border, #E2E8F0);
border-radius: 2px;
}
.reviews-front__wrap::-webkit-scrollbar-thumb {
background: var(--color-primary, #2563EB);
border-radius: 2px;
}
.reviews-front__grid {
flex-wrap: nowrap;
width: max-content;
gap: 16px;
}
.reviews-front__card {
width: 280px;
flex-shrink: 0;
}
.reviews-front__card-inner {
padding: 20px;
}
}
/* ---- CTA-секция (cta-consultation) ---- */
.cta-consultation {
@ -2061,72 +1882,17 @@
contain-intrinsic-size: 350px;
}
/* reviews-front-section — 3 карточки отзывов */
.reviews-front-section {
content-visibility: auto;
contain-intrinsic-size: 700px;
}
/* ════════════════════════════════════════════ */
/* Reviews Link CTA (moved from main.css) */
/* ════════════════════════════════════════════ */
.reviews-link {
background: var(--color-bg-alt);
border-radius: var(--radius-lg);
margin: 40px auto;
padding: 48px 24px;
}
.reviews-link .bento__full {
width: 100%;
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.reviews-link .bento-p {
max-width: 65ch;
margin-left: auto;
margin-right: auto;
}
.reviews-link .reviews-link__btn {
margin-top: 8px;
}
.reviews-link__btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 14px 32px;
border: none;
border-radius: var(--radius-sm, 12px);
font-family: inherit;
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text-on-dark);
cursor: pointer;
background: var(--pksh-gradient-btn-bright);
text-decoration: none;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
box-shadow: var(--pksh-btn-shadow-bright);
white-space: nowrap;
}
.reviews-link__btn:hover {
transform: translateY(-2px);
box-shadow: var(--pksh-btn-shadow-bright-hover);
}
.reviews-link__btn:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
/* ── Чередование фонов секций (в конце — переопределяет ранние правила) ── */
.services,
.faq-front,
.video-tour,
.stories,
.reviews-front-section {
.stories {
background-color: var(--color-bg-alt, #F8FAFC);
}
.comparison,

File diff suppressed because it is too large Load Diff

View File

@ -194,6 +194,17 @@
font-size: 0.875rem;
color: var(--color-text, #1E293B);
}
.teachers-card__about {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--color-text-muted, #475569);
margin: 0;
text-wrap: pretty;
}
.teachers-card__rating {
display: flex;
align-items: center;

File diff suppressed because it is too large Load Diff

View File

@ -87,7 +87,7 @@ document.addEventListener("click", function (event) {
});
try {
var swiperPopularSubjects = new Swiper(".swiper-popular-subjects", {
new Swiper(".swiper-popular-subjects", {
spaceBetween: 10,
slidesPerView: 1,
speed: 400,
@ -99,10 +99,10 @@ try {
},
navigation: { nextEl: ".subject-next", prevEl: ".subject-prev" }
});
} catch(e) { /* skip — swiper not on this page */ }
} catch { /* skip — swiper not on this page */ }
try {
var swiperFoto = new Swiper(".swiper-foto", {
new Swiper(".swiper-foto", {
spaceBetween: 10,
slidesPerView: 1,
speed: 400,
@ -111,15 +111,15 @@ try {
navigation: { nextEl: ".foto-next", prevEl: ".foto-prev" },
pagination: { el: ".swiper-pagination", clickable: true }
});
} catch(e) { /* skip — swiper not on this page */ }
} catch { /* skip — swiper not on this page */ }
try { var swiperNews = new Swiper(".swiper-news", { spaceBetween: 10, slidesPerView: 1, speed: 400, preventInteractionOnTransition: true, keyboard: true, breakpoints: { 680: { slidesPerView: 2, speed: 400 }, 860: { slidesPerView: 3, speed: 400 } }, navigation: { nextEl: ".news-next", prevEl: ".news-prev" } }); } catch(e) { /* skip */ }
try { new Swiper(".swiper-news", { spaceBetween: 10, slidesPerView: 1, speed: 400, preventInteractionOnTransition: true, keyboard: true, breakpoints: { 680: { slidesPerView: 2, speed: 400 }, 860: { slidesPerView: 3, speed: 400 } }, navigation: { nextEl: ".news-next", prevEl: ".news-prev" } }); } catch { /* skip */ }
try { var swiperCertificates = new Swiper(".certificates-owl", { spaceBetween: 10, slidesPerView: 1, speed: 400, loop: true, preventInteractionOnTransition: true, keyboard: true, navigation: { nextEl: ".certificates-owl-next", prevEl: ".certificates-owl-prev" } }); } catch(e) { /* skip */ }
try { new Swiper(".certificates-owl", { spaceBetween: 10, slidesPerView: 1, speed: 400, loop: true, preventInteractionOnTransition: true, keyboard: true, navigation: { nextEl: ".certificates-owl-next", prevEl: ".certificates-owl-prev" } }); } catch { /* skip */ }
try { var swiperReviews = new Swiper(".reviews-owl", { spaceBetween: 40, speed: 400, slidesPerView: 1, keyboard: true, breakpoints: { 860: { slidesPerView: 2, speed: 400 } }, navigation: { nextEl: ".reviews-owl-next", prevEl: ".reviews-owl-prev" } }); } catch(e) { /* skip */ }
try { new Swiper(".reviews-owl", { spaceBetween: 40, speed: 400, slidesPerView: 1, keyboard: true, breakpoints: { 860: { slidesPerView: 2, speed: 400 } }, navigation: { nextEl: ".reviews-owl-next", prevEl: ".reviews-owl-prev" } }); } catch { /* skip */ }
try { var swiperTeachers = new Swiper(".teachers-owl", { spaceBetween: 30, speed: 400, slidesPerView: 1, keyboard: true, breakpoints: { 540: { slidesPerView: 2 }, 860: { slidesPerView: 4 }, 992: { slidesPerView: 4, spaceBetween: 36 } }, navigation: { nextEl: ".teachers-owl-next", prevEl: ".teachers-owl-prev" } }); } catch(e) { /* skip */ }
try { new Swiper(".teachers-owl", { spaceBetween: 30, speed: 400, slidesPerView: 1, keyboard: true, breakpoints: { 540: { slidesPerView: 2 }, 860: { slidesPerView: 4 }, 992: { slidesPerView: 4, spaceBetween: 36 } }, navigation: { nextEl: ".teachers-owl-next", prevEl: ".teachers-owl-prev" } }); } catch { /* skip */ }
setTimeout(() => {
document.querySelectorAll('.b24-form-content .b24-form-btn').forEach(btn => {
@ -247,7 +247,7 @@ document.querySelectorAll('.header-menu').forEach(item => {
/* ── FAQ Accordion (mystyle-accordion) ── */
document.querySelectorAll('.mystyle-accordion-trigger').forEach(trigger => {
trigger.addEventListener('click', function(e) {
trigger.addEventListener('click', function() {
const item = this.closest('.mystyle-accordion-item');
if (!item) return;
const isOpen = item.classList.contains('active');

View File

@ -0,0 +1,103 @@
/**
* Админ: Настройки филиала вертикальные вкладки.
*
* Секции .branch-section превращаются в панели: слева список-навигация,
* справа содержимое активной вкладки. Активная вкладка запоминается
* (localStorage) и отражается в #hash удобно для ссылок/перезагрузок.
* Старый аккордеон (h2) остаётся в DOM, но скрыт.
*/
(function () {
'use strict';
var sections = document.querySelectorAll('.branch-section');
var form = document.querySelector('form[action="options.php"]');
if (! form || sections.length < 2) {
return;
}
var list = Array.prototype.slice.call(sections);
// ── Каркас: навигация + контент ──
var wrap = document.createElement('div');
wrap.className = 'dekart-branch-tabs';
var nav = document.createElement('nav');
nav.className = 'dekart-branch-tabs__nav';
nav.setAttribute('aria-label', 'Разделы настроек филиала');
var ul = document.createElement('ul');
nav.appendChild(ul);
var content = document.createElement('div');
content.className = 'dekart-branch-tabs__content';
// Названия вкладок — из скрытых h2 секций.
var buttons = [];
list.forEach(function (section, i) {
var h2 = section.querySelector(':scope > h2');
var label = h2 ? h2.textContent.trim() : ('Раздел ' + (i + 1));
var li = document.createElement('li');
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.dataset.tab = String(i);
li.appendChild(btn);
ul.appendChild(li);
buttons.push(btn);
});
// Встраиваем каркас ПЕРЕД перемещением секций (form.insertBefore
// с отвязанным узлом падал бы — секции оставались вне формы).
form.insertBefore(wrap, list[0]);
wrap.appendChild(nav);
wrap.appendChild(content);
// Переносим секции в область контента уже после подключения каркаса.
list.forEach(function (section) {
section.classList.remove('collapsed');
section.setAttribute('role', 'tabpanel');
content.appendChild(section);
});
var activate = function (i) {
i = Math.max(0, Math.min(i, list.length - 1));
list.forEach(function (section, j) {
section.classList.toggle('is-active', j === i);
});
buttons.forEach(function (btn, j) {
btn.classList.toggle('is-active', j === i);
});
try {
localStorage.setItem('dekart_branch_tab', String(i));
} catch { /* приватный режим — не критично */ }
};
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
var i = parseInt(btn.dataset.tab, 10);
activate(i);
if (history.replaceState) {
history.replaceState(null, '', '#tab=' + (i + 1));
}
});
});
// Стартовая вкладка: #hash > localStorage > первая.
var start = 0;
var hashMatch = (location.hash || '').match(/tab=(\d+)/);
if (hashMatch) {
start = parseInt(hashMatch[1], 10) - 1;
} else {
try {
var saved = parseInt(localStorage.getItem('dekart_branch_tab'), 10);
if (! isNaN(saved) && saved >= 0 && saved < list.length) {
start = saved;
}
} catch { /* ignore */ }
}
// submit остаётся под контентом.
activate(start);
})();

View File

@ -0,0 +1,43 @@
/**
* Админ: страховка вкладок Carbon Fields (преподаватель).
*
* Carbon строит вкладки через React не мгновенно на короткое время поля
* могут быть видны списком. Если после полной загрузки вкладки так и не
* собрались (класс cf-container--tabbed есть, списка вкладок нет) это
* сбой инициализации CF: помечаем метабокс красной рамкой и логируем,
* чтобы починить корень, а не камуфлировать.
*
* Никаких авто-перезагрузок: можно потерять несохранённые правки.
*/
(function () {
'use strict';
if (! document.body.classList.contains('post-type-teacher')) {
return;
}
var markBroken = function () {
document.body.classList.add('dekart-cf-tabs-broken');
if (window.console && console.warn) {
console.warn('[dekart] Carbon Fields: вкладки преподавателя не собрались. Обновите страницу (если нет несохранённых правок) или проверьте консоль на ошибки.');
}
};
window.addEventListener('load', function () {
var checks = 0;
var timer = window.setInterval(function () {
var tabbed = document.querySelector('.cf-container--tabbed');
var nav = document.querySelector('.cf-container__tabs-list');
if (nav || ! tabbed) {
window.clearInterval(timer);
return;
}
checks += 1;
if (checks >= 10) { // ~4 сек: CF так долго не собирает вкладки
window.clearInterval(timer);
markBroken();
}
}, 400);
});
})();

View File

@ -1,8 +1,8 @@
// Gallery modal
document.addEventListener('click', function(e) {
var modal = document.getElementById('lw-modal-gallery');
var thumb = e.target.closest('.lw-gallery__thumb');
if (thumb) {
var modal = document.getElementById('lw-modal-gallery');
var img = modal.querySelector('.lw-modal__img');
img.src = thumb.dataset.full;
modal.classList.add('lw-modal--open');
@ -11,7 +11,6 @@ document.addEventListener('click', function(e) {
}
var close = e.target.closest('.lw-modal__close, .lw-modal__overlay');
if (close) {
var modal = document.getElementById('lw-modal-gallery');
modal.classList.remove('lw-modal--open');
document.body.style.overflow = '';
return;
@ -33,10 +32,10 @@ const btnCloseModalMentors = document.querySelector('.btn-close-mentors');
const modalPrograms = document.querySelector('.modal-programs');
const modalMentors = document.querySelector('.modal-mentors');
btnCloseModalPrograms.addEventListener("click", function (event) {
btnCloseModalPrograms.addEventListener("click", function () {
modalPrograms.classList.remove('open');
});
btnCloseModalMentors.addEventListener("click", function (event) {
btnCloseModalMentors.addEventListener("click", function () {
modalMentors.classList.remove('open');
});

View File

@ -0,0 +1,51 @@
/**
* Личная страница преподавателя кейсы (табы), sticky CTA (mobile).
* Reveal-анимации глобальные (add.js), здесь только интерактив страницы.
*/
(function () {
'use strict';
// ── Кейсы: табы 9/11 ──
var tabs = document.querySelectorAll('.teacher-single__case-tab');
var panels = document.querySelectorAll('.teacher-single__case-panel');
if (tabs.length && panels.length) {
Array.prototype.forEach.call(tabs, function (tab) {
tab.addEventListener('click', function () {
var value = tab.getAttribute('data-case-tab');
Array.prototype.forEach.call(tabs, function (t) {
var active = t === tab;
t.classList.toggle('is-active', active);
t.setAttribute('aria-selected', active ? 'true' : 'false');
});
Array.prototype.forEach.call(panels, function (panel) {
panel.classList.toggle('is-active', panel.getAttribute('data-case-panel') === value);
});
});
});
}
// ── Sticky CTA (mobile): показать после hero ──
var sticky = document.querySelector('.teacher-single__sticky');
if (sticky) {
var hero = document.querySelector('.teacher-single__hero');
if (hero) {
var showSticky = function () {
sticky.hidden = window.scrollY < hero.offsetHeight - 80;
};
showSticky();
var ticking = false;
window.addEventListener('scroll', function () {
if (!ticking) {
window.requestAnimationFrame(function () {
showSticky();
ticking = false;
});
ticking = true;
}
}, { passive: true });
} else {
sticky.hidden = false;
}
}
})();

View File

@ -766,6 +766,19 @@ function add_carbon()
}
}
// Программы из каталога филиала (Настройки → Филиал → «Программы и направления»).
// Преподаватель только выбирает программу; цены/ссылки — из каталога.
$teacher_program_options = array(
'' => '— Выберите программу —',
);
$branch_programs_catalog = (array) get_option('branch_programs', array());
foreach ($branch_programs_catalog as $bp_item) {
$bp_name = trim((string) ( $bp_item['name'] ?? '' ));
if ('' !== $bp_name && ! isset($teacher_program_options[ $bp_name ])) {
$teacher_program_options[ $bp_name ] = $bp_name;
}
}
// Иконки для плашек hero — только из SVG-спрайта (use href="#icon-*")
$school_hero_float_icons = array(
'teacher' => '👨‍🏫 Учитель',
@ -1460,43 +1473,60 @@ function add_carbon()
// ====================================================
Container::make('post_meta', 'Поля для преподавателя')
->where('post_type', '=', 'teacher')
->add_fields(array(
->add_tab('🎬 Hero', array(
Field::make('html', 'teacher_hero_tab_desc', '')
->set_html('<p style="color:#64748b;font-size:13px;margin:0 0 12px;">Фото и текст под заголовком на личной странице преподавателя. Если текст пуст — подставляется автоматически (направление + мини-группа + экзамен + школа и город).</p>'),
Field::make('image', 'teacher_avatar', 'Фото преподавателя (hero/карточка):')
->set_width(30),
Field::make('textarea', 'teacher_hero_subtitle', 'Текст под H1 (subtitle)')
->set_width(100)
->set_help_text('Пример: «Учитель начальных классов — мини-группы 2-6 чел. · ОГЭ/ЕГЭ · школа «Декарт» в Щёлково». Пусто — соберётся автоматически.'),
Field::make('text', 'teacher_rating', 'Оценка преподавателя (для hero и микроразметки)')
->set_width(25)
->set_default_value('5')
->set_help_text('Пример: <strong>5</strong> или <strong>4,5</strong>. Звёзды в hero и aggregateRating в Schema.org.'),
Field::make('text', 'teacher_group_size', 'Размер мини-группы (для hero, курсов, шагов и CTA)')
->set_width(25)
->set_default_value('2-6')
->set_help_text('Пример: <strong>2-6</strong>. Участвует в hero, курсах, шагах, финальном CTA и микроразметке — заполняйте всегда.'),
))
->add_tab('👤 Профиль и данные', array(
Field::make('text', 'teacher_direction', 'Направление преподавателя (текст в под фото):'),
Field::make('textarea', 'teacher_description', 'Дескрипшн преподавателя (коротко — для карточек)')
->set_width(100)
->set_help_text('Выводится в карточке преподавателя на главной и на странице «Наши преподаватели». Максимум вывода в карточке — 3 строки. Рекомендуемый размер: до 140 знаков. Более длинный текст в карточке обрезается многоточием.'),
Field::make('text', 'teacher_badge', 'Бейдж на карточке (например: 👶 Работает с дошкольниками)')
->set_width(30)
->set_help_text('Текст бейджа на карточке преподавателя. Если пусто — бейдж скрыт.'),
Field::make('image', 'teacher_avatar', 'Аватар:')
->set_width(15),
Field::make('text', 'teacher_start_year', 'Год начала работы (только год, например: 1987)')
->set_width(30)
->set_attribute('placeholder', '1987')
->set_help_text('Стаж рассчитается автоматически на странице подготовки к школе.'),
Field::make('textarea', 'teacher_description', 'Дескрипшн преподавателя')
->set_width(55),
Field::make('textarea', 'teacher_about', 'О себе:'),
))
->add_tab('📄 О преподавателе', array(
Field::make('textarea', 'teacher_about', 'О себе:')
->set_width(100)
->help_text('Обычный текст, вёрстка не нужна — абзацы разделяйте пустой строкой (Enter + пустая строка). Рекомендуемый объём: 6001200 знаков (24 коротких абзаца).'),
Field::make('textarea', 'teacher_education', 'Образование:'),
Field::make('textarea', 'teacher_add_education', 'Дополнительное образование:'),
Field::make('text', 'teacher_rating', 'Оценка преподавателя')
->set_width(10)
->set_default_value('5'),
Field::make('text', 'teacher_diploma', 'Учебное заведение (для микроразметки alumniOf)')
->set_width(50)
->help_text('Одно главное учебное заведение (напр. «Московский педагогический государственный университет (МПГУ)»). Используется в Schema.org для свойства alumniOf. Если пусто — свойство не выводится.'),
Field::make('text', 'teacher_video', 'Ссылка на видеопрезентацию')
->set_width(100)
->help_text('Укажите ссылку на видео в youtube. Пример: &lt;iframe src="https://vk.com/video_ext.php?oid=-43228812&id=456240170hd=2" width="853" height="480" allow="autoplay; encrypted-media; fullscreen; picture-in-picture;" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;'),
))
->add_tab('📚 Предметы и документы', array(
Field::make('complex', 'teacher_subjects', 'Предметы обучения')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('text', 'teacher_subjects_title', 'Укажите заголовок предмета')
Field::make('select', 'teacher_subjects_title', 'Программа (направление)')
->set_options($teacher_program_options)
->set_width(50)
->set_required(true)
->help_text('Например: Репетитор по математике'),
Field::make('textarea', 'select_direction', 'Укажите все направления преподавателя:')
->help_text('Пример: &lt;span&gt;Математика&lt;/span&gt;&lt;span&gt;Русский язык&lt;/span&gt;'),
Field::make('association', 'select_category', 'Укажите категрии')
->set_width(100)
->set_types(array(
array(
'type' => 'term',
'taxonomy' => 'trainingCategory',
)
))
->help_text('В списке указаны все возможные категрии в обучении, выберите необходимые'),
->help_text('Выберите из каталога программ филиала (Настройки → Филиал → «Программы и направления»). Цена и ссылка на страницу подтянутся из каталога.'),
Field::make('textarea', 'teacher_subjects_topics', 'Темы направления (каждая с новой строки)')
->set_help_text('Кликабельный список: «Математика», «Русский язык»… Каждая тема — с новой строки. Пусто — блок тем скрыт.'),
)),
Field::make('select', 'teacher_category', 'Категория для фильтра на странице преподавателей')
->set_width(50)
@ -1508,10 +1538,106 @@ function add_carbon()
Field::make('image', 'certificates_foto', 'Изображение')
))
->help_text('Добавьте изображение дипломов и сертификатов преподавателя'),
Field::make('text', 'teacher_video', 'Ссылка на видеопрезентацию')
))
->add_tab('📊 Цифры и дефицит', array(
Field::make('html', 'teacher_metrics_help')
->set_html('<div style="background:#F8FAFC;border:1px solid #E2E8F0;border-radius:8px;padding:12px 14px;margin:0 0 14px;color:#334155;font-size:13px;line-height:1.6"><strong>Блок «Цифры»</strong> — карточки под hero (рейтинг, средний балл, цифра про систему, размер группы и любые другие факты). Кнопкой <strong>«+ Добавить»</strong> создаёте карточку, крестиком рядом с полями — удаляете. В каждой карточке два поля: <strong>цифра</strong> и <strong>подпись</strong>. Порядок карточек = порядок вывода на странице. Пустые карточки не выводятся. Если карточек нет — блок «Цифры» скрыт.</div>'),
Field::make('complex', 'teacher_metrics', 'Карточки цифр')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('text', 'metric_num', 'Цифра / факт')
->set_width(25)
->set_required(true)
->set_help_text('Короткое значение-результат. Примеры: <strong>5/5</strong>, <strong>100%</strong>, <strong>2-6</strong>, <strong>4,7</strong>, <strong>45 минут</strong>, <strong>500+</strong>.'),
Field::make('text', 'metric_text', 'Подпись под цифрой')
->set_width(75)
->set_help_text('Что означает цифра. Примеры: <strong>«Рейтинг преподавателя · 5 отзывов»</strong>, <strong>«учеников продолжают обучение на следующий учебный год»</strong>, <strong>«человек в мини-группе — внимание как на репетиторе»</strong>, <strong>«средний балл учеников»</strong>.'),
))
->set_help_text('«+ Добавить» — новая карточка (цифра + подпись). Нет карточек — блок «Цифры» на странице скрыт, используйте Hero-таб для рейтинга и размера группы.'),
Field::make('html', 'teacher_scarcity_sep', '')
->set_html('<hr><h3>⏳ Полоса дефицита</h3><p style="color:#64748B;font-size:12px;margin:4px 0 10px;">Жёлтая полоса под цифрами: счётчик дней до экзамена + места в группе + пояснение. Пустое поле — пункт скрыт.</p>'),
Field::make('text', 'teacher_seats_left', 'Осталось мест в группе')
->set_width(25)
->set_default_value('4')
->set_help_text('Пример: <strong>4</strong>. Полоса дефицита: «4 места в группе». Пусто/0 — пункт скрыт.'),
Field::make('text', 'teacher_exam_label', 'Экзамен/формат')
->set_width(25)
->set_default_value('ОГЭ/ЕГЭ')
->set_help_text('Пример: <strong>«ОГЭ/ЕГЭ»</strong>. Полоса дефицита: «дней до ОГЭ/ЕГЭ».'),
Field::make('text', 'teacher_exam_date', 'Дата экзамена для счётчика (YYYY-MM-DD)')
->set_width(25)
->set_default_value('')
->set_help_text('Пример: <strong>2027-05-24</strong>. Пусто — счётчик дней скрыт.'),
Field::make('text', 'teacher_scarcity_note', 'Текст полосы дефицита')
->set_width(100)
->help_text('Укажите ссылку на видео в youtube. Пример: &lt;iframe src="https://vk.com/video_ext.php?oid=-43228812&id=456240170hd=2" width="853" height="480" allow="autoplay; encrypted-media; fullscreen; picture-in-picture;" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;'),
Field::make('complex', 'questions', 'Блок вопросы и ответы')
->set_default_value('Набор в группу идёт каждый месяц, но места ограничены.')
->set_help_text('Пример: <strong>«Набор в группу идёт каждый месяц, но места ограничены»</strong>. Пусто — скрыто.'),
))
->add_tab('🎨 Сегменты, кейсы, шаги', array(
Field::make('complex', 'teacher_segments', 'Сегменты учеников (карточки)')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('text', 'segment_title', 'Заголовок карточки')
->set_width(30),
Field::make('textarea', 'segment_text', 'Текст карточки')
->set_width(70),
))
->set_help_text('3 карточки «Для кого занятия». Пусто — покажутся 3 стандартные.'),
Field::make('complex', 'teacher_cases', 'Кейсы учеников (табы 9/11 класс)')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('text', 'case_tab', 'Таб (9 или 11)')
->set_width(20),
Field::make('textarea', 'case_text', 'История кейса')
->set_width(80),
))
->set_help_text('Кейсы с результатом. tab: 9 или 11 класс.'),
Field::make('complex', 'teacher_steps', 'Шаги «Как проходит первое занятие»')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('text', 'step_title', 'Название шага')
->set_width(30),
Field::make('textarea', 'step_text', 'Описание шага')
->set_width(70),
))
->set_help_text('Таймлайн: от заявки до обратной связи. Пусто — 5 стандартных шагов.'),
))
->add_tab('📈 Результаты учеников', array(
Field::make('html', 'teacher_results_help')
->set_html('<div style="background:#F8FAFC;border:1px solid #E2E8F0;border-radius:8px;padding:12px 14px;margin:0 0 14px;color:#334155;font-size:13px;line-height:1.6"><strong>Блок «Измеримые результаты»</strong> — отдельная секция на странице (не путать с «Кому подойдут»). 36 пунктов: короткий измеримый результат + пояснение. Пусто — блок соберётся из заполненных метрик преподавателя (рейтинг, стаж, средний балл, отзывы).</div>'),
Field::make('complex', 'teacher_results', 'Измеримые результаты')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('text', 'result_title', 'Короткий результат (цифра/факт)')
->set_width(30)
->set_required(true)
->help_text('Например: «+2 балла», «100%», «45 минут».'),
Field::make('textarea', 'result_text', 'Пояснение')
->set_width(70)
->help_text('Например: «средний рост успеваемости за полугодие».'),
))
->set_help_text('36 пунктов. Пусто — собирается из метрик преподавателя.'),
Field::make('html', 'teacher_results_fallback_sep', '')
->set_html('<hr><h3>⚙ Авто-пункты (если «Измеримые результаты» пустые)</h3><p style="color:#64748B;font-size:12px;margin:4px 0 10px;">Подставляются в секцию результатов, пока complex выше не заполнен. Заполните — complex перекроет авто-пункты.</p>'),
Field::make('text', 'teacher_avg_score', 'Средний балл учеников')
->set_width(25)
->set_default_value('')
->set_help_text('Авто-пункт. Пример: <strong>4,7</strong>. Пусто — пункт скрыт.'),
Field::make('text', 'teacher_avg_label', 'Подпись среднего балла')
->set_width(25)
->set_default_value('Средний балл учеников')
->set_help_text('Пример: <strong>«Средний балл учеников»</strong>.'),
Field::make('text', 'teacher_school_num', 'Цифра про систему/школу')
->set_width(25)
->set_default_value('100%')
->set_help_text('Авто-пункт. Пример: <strong>100%</strong>. Пусто — пункт скрыт.'),
Field::make('text', 'teacher_school_label', 'Подпись к цифре про систему')
->set_width(25)
->set_default_value('учеников продолжают обучение на следующий учебный год')
->set_help_text('Пример: <strong>«учеников продолжают обучение на следующий учебный год»</strong>.'),
))
->add_tab('❓ Вопросы и отзывы', array(
Field::make('complex', 'questions', 'Блок вопросы и ответы (страница)')
->set_layout('tabbed-horizontal')
->add_fields(array(
Field::make('textarea', 'questions_question', 'Текст вопроса')
@ -1519,113 +1645,25 @@ function add_carbon()
Field::make('textarea', 'questions_answer', 'Текст ответа')
->set_width(70),
)),
// ═══════════════ SEO и конверсия личной страницы (Bento 2026) ═══════════════
Field::make('html', 'teacher_conv_seo_desc', '')
->set_html('<h3 style="margin:18px 0 6px;font-size:15px;">SEO-мета (title/description страницы преподавателя)</h3><p style="color:#64748b;font-size:13px;margin:0 0 12px;">Если title/description пустые — собираются автоматически: предмет, мини-группы 2-6 чел., год экзамена, средний балл, остаток мест.</p>'),
Field::make('text', 'teacher_meta_subject', 'Предмет для мета и заголовков')
->set_width(50)
->set_default_value('')
->set_help_text('Например: «Математика». Пусто — берётся «Направление преподавателя».'),
Field::make('text', 'teacher_exam_label', 'Экзамен/формат (для title)')
->set_width(25)
->set_default_value('ОГЭ/ЕГЭ'),
Field::make('text', 'teacher_exam_year', 'Год экзамена (для title)')
->set_width(25)
->set_default_value('')
->set_help_text('Пусто — следующий год. Пример: 2027.'),
Field::make('text', 'teacher_group_size', 'Размер мини-группы')
->set_width(25)
->set_default_value('2-6'),
Field::make('text', 'teacher_seats_left', 'Осталось мест в группе')
->set_width(25)
->set_default_value('4'),
Field::make('text', 'teacher_avg_score', 'Средний балл выпускников')
->set_width(25)
->set_default_value('')
->set_help_text('Пример: 4,7. Пусто — блок скрыт.'),
Field::make('textarea', 'teacher_meta_title_override', 'Свой title (перекроет авто)')
->set_width(50),
Field::make('textarea', 'teacher_meta_desc_override', 'Свой description (перекроет авто)')
->set_width(50),
// ── Hero: второй CTA и риск ──
Field::make('html', 'teacher_conv_hero_desc', '')
->set_html('<h3 style="margin:18px 0 6px;font-size:15px;">Hero: CTA и снятие риска</h3>'),
Field::make('text', 'teacher_cta_primary', 'Текст главной кнопки')
->set_width(50)
->set_default_value('Записаться на диагностику'),
Field::make('text', 'teacher_schedule_label', 'Текст кнопки «Расписание»')
->set_width(50)
->set_default_value('Посмотреть расписание групп'),
Field::make('text', 'teacher_schedule_url', 'Ссылка «Расписание» (URL)')
->set_width(50)
->set_help_text('Например: https://dekart-school.ru/raspisanie/'),
Field::make('text', 'teacher_risk_note', 'Микро-текст под кнопками')
->set_width(100)
->set_default_value('Перезвоним за 15 минут · Без обязательств'),
// ── Цифры и школа ──
Field::make('html', 'teacher_conv_numbers_desc', '')
->set_html('<h3 style="margin:18px 0 6px;font-size:15px;">Цифры (преподаватель + система)</h3>'),
Field::make('text', 'teacher_school_num', 'Цифра про систему/школу')
->set_width(25)
->set_default_value('100%'),
Field::make('text', 'teacher_school_label', 'Подпись к цифре про систему')
->set_width(75)
->set_default_value('выпускников мини-групп поступили на бюджет'),
Field::make('text', 'teacher_exam_date', 'Дата экзамена для счётчика (YYYY-MM-DD)')
->set_width(50)
->set_help_text('Пример: 2027-05-24. Пусто — блок дефицита без счётчика дней.'),
Field::make('text', 'teacher_scarcity_note', 'Текст полосы дефицита')
->set_width(100)
->set_default_value('Набор в группу идёт каждый месяц, но места ограничены.'),
// ── Сегменты / кейсы / шаги ──
Field::make('html', 'teacher_conv_content_desc', '')
->set_html('<h3 style="margin:18px 0 6px;font-size:15px;">Сегменты, кейсы, «Как проходит занятие»</h3>'),
Field::make('complex', 'teacher_segments', 'Сегменты учеников (карточки)')
->set_layout('tabbed-horizontal')
->set_help_text('3 карточки «Для кого занятия». Пусто — покажутся 3 стандартные.')
->add_fields(array(
Field::make('text', 'segment_title', 'Заголовок карточки')
->set_width(30),
Field::make('textarea', 'segment_text', 'Текст карточки')
->set_width(70),
)),
Field::make('complex', 'teacher_cases', 'Кейсы учеников (табы 9/11 класс)')
->set_layout('tabbed-horizontal')
->set_help_text('Кейсы с результатом. tab: 9 или 11 класс.')
->add_fields(array(
Field::make('text', 'case_tab', 'Таб (9 или 11)')
->set_width(20),
Field::make('textarea', 'case_text', 'История кейса')
->set_width(80),
)),
Field::make('complex', 'teacher_steps', 'Шаги «Как проходит первое занятие»')
->set_layout('tabbed-horizontal')
->set_help_text('Таймлайн: от заявки до обратной связи. Пусто — 5 стандартных шагов.')
->add_fields(array(
Field::make('text', 'step_title', 'Название шага')
->set_width(30),
Field::make('textarea', 'step_text', 'Описание шага')
->set_width(70),
)),
// ── FAQ / доверие ──
Field::make('html', 'teacher_conv_faq_desc', '')
->set_html('<h3 style="margin:18px 0 6px;font-size:15px;">FAQ и доверие</h3>'),
Field::make('complex', 'teacher_faq_extra', 'Доп. вопросы (про мини-группы)')
->set_layout('tabbed-horizontal')
->set_help_text('Вопросы про формат: «Чем мини-группа лучше репетитора?» и т.п. Пусто — 2 стандартных.')
->add_fields(array(
Field::make('textarea', 'faq_q', 'Вопрос')
->set_width(30),
Field::make('textarea', 'faq_a', 'Ответ')
->set_width(70),
)),
Field::make('textarea', 'teacher_license', 'Текст блока «Лицензия и доверие»')
->set_default_value('Частная школа «Декарт» работает по лицензии на образовательную деятельность (бессрочная). Копия лицензии и реквизиты — по запросу у администратора. Рейтинг на Яндекс Картах и 2ГИС формируется из реальных отзывов родителей.')
->set_help_text('Показывается в блоке доверия перед картой.'),
))
->set_help_text('Вопросы про формат: «Чем мини-группа лучше репетитора?» и т.п. Пусто — 2 стандартных.'),
Field::make('checkbox', 'teacher_show_social_reviews', 'Показывать «Отзывы о преподавателе» на странице')
->set_option_value('yes')
->set_default_value('yes'),
));
))
->add_tab('📜 Лицензия и доверие', array(
Field::make('textarea', 'teacher_license', 'Текст блока «Лицензия и доверие»')
->set_default_value('Частная школа «Декарт» работает по лицензии на образовательную деятельность (бессрочная). Копия лицензии и реквизиты — по запросу у администратора. Рейтинг на Яндекс Картах и 2ГИС формируется из реальных отзывов родителей.')
->set_help_text('Показывается в блоке доверия перед картой.'),
))->set_layout('tabbed-vertical');
// ====================================================
// Доп поля для страницы "Преподаватели" (Bento 2026)
@ -2824,6 +2862,21 @@ function enqueue_template_specific_styles()
true
);
}
if (is_singular('teacher')) {
wp_enqueue_style(
'single-teacher-style',
get_template_directory_uri() . '/assets/css/single-teacher.css',
array(),
filemtime(get_template_directory() . '/assets/css/single-teacher.css')
);
wp_enqueue_script(
'single-teacher-script',
get_template_directory_uri() . '/assets/js/single-teacher.js',
array(),
filemtime(get_template_directory() . '/assets/js/single-teacher.js'),
true
);
}
if (is_page_template('page-after-school.php')) {
wp_enqueue_style(
'template-after-style',
@ -3131,29 +3184,55 @@ function dekart_teacher_review_counts(): array
foreach ($review_ids as $review_id) {
$teacher_id = 0;
foreach ($link_keys as $key) {
$raw = get_post_meta((int) $review_id, $key, true);
if (empty($raw)) {
continue;
}
$value = maybe_unserialize($raw);
$ids = array();
if (is_array($value)) {
foreach ($value as $entry) {
if (is_object($entry) && isset($entry->id)) {
$ids[] = (int) $entry->id;
} elseif (is_numeric($entry)) {
$ids[] = (int) $entry;
// Основной путь: Carbon Fields Association (ключи вида _review_teacher|||0|id)
if (function_exists('carbon_get_post_meta')) {
$cached = carbon_get_post_meta((int) $review_id, 'review_teacher');
if (is_array($cached)) {
foreach ($cached as $entry) {
if (is_array($entry) && ! empty($entry['id'])) {
$teacher_id = (int) $entry['id'];
break;
}
if (is_string($entry) && preg_match('/post:teacher:(\d+)/', $entry, $m)) {
$teacher_id = (int) $m[1];
break;
}
}
} elseif (is_numeric($value)) {
$ids[] = (int) $value;
}
if (! empty($ids)) {
$teacher_id = $ids[0];
break;
}
}
// Legacy fallback: сырые ключи меты
if (! $teacher_id) {
foreach ($link_keys as $key) {
$raw = get_post_meta((int) $review_id, $key, true);
if (empty($raw)) {
continue;
}
$value = maybe_unserialize($raw);
$ids = array();
if (is_array($value)) {
foreach ($value as $entry) {
if (is_object($entry) && isset($entry->id)) {
$ids[] = (int) $entry->id;
} elseif (is_array($entry) && isset($entry['id'])) {
$ids[] = (int) $entry['id'];
} elseif (is_string($entry) && preg_match('/post:teacher:(\d+)/', $entry, $m)) {
$ids[] = (int) $m[1];
} elseif (is_numeric($entry)) {
$ids[] = (int) $entry;
}
}
} elseif (is_numeric($value)) {
$ids[] = (int) $value;
}
if (! empty($ids)) {
$teacher_id = $ids[0];
break;
}
}
}
if ($teacher_id > 0) {
$map[ $teacher_id ] = isset($map[ $teacher_id ]) ? $map[ $teacher_id ] + 1 : 1;
}
@ -3315,6 +3394,32 @@ function dekart_add_preload_hints()
}
add_action('wp_head', 'dekart_add_preload_hints', 1);
// ====================================================
// ✅ Hardening: XML-RPC + REST user enumeration (пред-продакшен)
// ====================================================
// XML-RPC полностью выключен (защита от pingback DDoS и брутфорса через system.multicall)
add_filter('xmlrpc_enabled', '__return_false');
// Убрать ВСЕ XML-RPC методы (включая анонимные pingback.* / system.* — вектор амплификации).
// WP 7.1: xmlrpc_enabled=false не блокирует анонимные методы, поэтому дублируем.
add_filter('xmlrpc_methods', '__return_empty_array');
// Закрыть REST-эндпоинты пользователей для анонимов (wp/v2/users раскрывает логины)
add_filter('rest_endpoints', static function (array $endpoints): array {
if (is_user_logged_in()) {
return $endpoints;
}
foreach (array_keys($endpoints) as $route) {
if (preg_match('#^/wp/v2/users(/|$)#', $route)) {
unset($endpoints[$route]);
}
}
return $endpoints;
});
/* ── Honest timer: end-of-month deadline ── */
/**
* Return end-of-current-month as ISO 8601 string.
@ -3373,13 +3478,10 @@ function dekart_acf_delete_json($post_id)
}
// ─────────────────────────────────────────────────────────────
// Счетчики — централизованное управление (ТОП-меню)
add_action('admin_menu', 'dekart_counters_menu');
function dekart_counters_menu()
{
add_menu_page('Счетчики', 'Счетчики', 'manage_options', 'site-analytics-settings', 'dekart_counters_page', 'dashicons-chart-bar', 3);
}
// Счетчики — перенесены во вкладку «Настройки филиала»
// (ТОП-меню удалено; опции зарегистрированы в dekart_branch_group;
// вывод на фронте — те же хуки wp_head/wp_body_open/wp_footer)
// ─────────────────────────────────────────────────────────────
add_action('admin_init', 'dekart_counters_settings');
function dekart_counters_settings()
{
@ -3490,8 +3592,7 @@ function dekart_output_counters_footer()
$screen = get_current_screen();
if (! $screen) { return; }
$is_carbon = strpos($screen->id, 'carbon_fields') !== false;
$is_branch = strpos($screen->id, 'branch-settings') !== false;
if (! $is_carbon && ! $is_branch) { return; }
if (! $is_carbon) { return; }
?>
<style>
:root {
@ -3507,10 +3608,8 @@ function dekart_output_counters_footer()
--cf-warning: #fbbf24;
}
/* Общий фон страницы */
body.branch-settings_page_branch-settings,
body.branch-settings_page_carbon_fields,
body.toplevel_page_carbon_fields,
body.toplevel_page_branch-settings {
body.toplevel_page_carbon_fields {
background: var(--cf-bg) !important;
}
#wpbody-content .wrap {
@ -3710,6 +3809,8 @@ function dekart_branch_register_settings()
'branch_email' => 'email',
'branch_name' => 'text',
'branch_price_range' => 'text',
'branch_price' => 'text',
'branch_price_note' => 'text',
'branch_logistics_walk' => 'text',
'branch_logistics_parking' => 'text',
'branch_logistics_schools' => 'text',
@ -3749,7 +3850,9 @@ function dekart_branch_register_settings()
register_setting('dekart_branch_group', 'branch_messenger_max_on', array( 'sanitize_callback' => 'absint' ));
register_setting('dekart_branch_group', 'branch_messenger_extra');
register_setting('dekart_branch_group', 'branch_schema_description', array( 'sanitize_callback' => 'sanitize_textarea_field' ));
register_setting('dekart_branch_group', 'branch_license', array( 'sanitize_callback' => 'sanitize_textarea_field' ));
register_setting('dekart_branch_group', 'branch_area_served', array( 'sanitize_callback' => 'sanitize_text_field' ));
register_setting('dekart_branch_group', 'branch_programs', array( 'sanitize_callback' => 'dekart_sanitize_branch_programs' ));
register_setting('dekart_branch_group', 'branch_price_school', array( 'sanitize_callback' => 'sanitize_text_field' ));
register_setting('dekart_branch_group', 'branch_price_preschool', array( 'sanitize_callback' => 'sanitize_text_field' ));
register_setting('dekart_branch_group', 'branch_price_afterschool', array( 'sanitize_callback' => 'sanitize_text_field' ));
@ -3768,6 +3871,13 @@ function dekart_branch_register_settings()
// Поля branch_map_url, branch_founded, branch_photos — удалены.
// Они дублировали CF таб «SEO и микроразметка» (schema_*).
// Чтение: inc/schema.php → dekart_schema_option() → CF → Settings API (backward compat).
// Счётчики (перенесены из ТОП-меню «Счетчики» во вкладку филиала).
// Регистрация в группе филиала, чтобы сохранялись одной кнопкой.
// Старая группа dekart_counters_group остаётся для обратной совместимости.
foreach (array( 'dekart_counter_yandex', 'dekart_counter_gtm_head', 'dekart_counter_gtm_body', 'dekart_counter_footer' ) as $counter_option) {
register_setting('dekart_branch_group', $counter_option);
}
}
function dekart_branch_settings_page()
@ -3890,6 +4000,11 @@ function dekart_branch_settings_page()
<input type="text" name="branch_director_creds" value="<?php echo esc_attr(get_option('branch_director_creds', 'директор частной школы «Декарт», педагогический стаж 18 лет')); ?>">
<p class="desc">Должность и достижения. Например: «директор частной школы «Декарт», педагогический стаж 18 лет»</p>
</div>
<div class="branch-field">
<label>Лицензия (для Schema.org hasCredential)</label>
<textarea name="branch_license" rows="2" style="width:100%;"><?php echo esc_textarea(get_option('branch_license', '')); ?></textarea>
<p class="desc">Текст про лицензию на образовательную деятельность. Выводится в микроразметке (hasCredential) и в блоке «Работаем официально».</p>
</div>
</div>
</div>
@ -4014,6 +4129,65 @@ function dekart_branch_settings_page()
</div>
</div>
<div class="branch-section collapsed">
<h2>🎓 ПРОГРАММЫ И НАПРАВЛЕНИЯ</h2>
<div class="branch-section-body">
<p class="desc">Единый каталог программ/направлений школы: <strong>название, цена, пояснение к цене, ссылка на страницу программы, теги каталога</strong>. Цены отсюда попадают в карточки преподавателей и в микроразметку Offer. Преподаватели <strong>выбирают</strong> программу из каталога (Настройки преподаватель «Предметы обучения») свои теги направления у них задаются облаком тегов отдельно.</p>
<div id="branch-programs-repeater">
<?php
$programs = get_option('branch_programs', array());
if (! is_array($programs) || empty($programs)) {
$programs = array( array( 'name' => '', 'price' => '', 'price_note' => '', 'url' => '', 'tags' => '' ) );
}
foreach ($programs as $i => $p) {
echo '<div class="branch-program-row">'
. '<input type="text" name="branch_programs[' . $i . '][name]" value="' . esc_attr($p['name'] ?? '') . '" placeholder="Название (напр. Нейропсихолог)">'
. '<input type="text" name="branch_programs[' . $i . '][price]" value="' . esc_attr($p['price'] ?? '') . '" placeholder="Цена (напр. от 1 290 ₽)" style="max-width:170px;">'
. '<input type="text" name="branch_programs[' . $i . '][price_note]" value="' . esc_attr($p['price_note'] ?? '') . '" placeholder="Пояснение к цене" style="max-width:200px;">'
. '<input type="url" name="branch_programs[' . $i . '][url]" value="' . esc_attr($p['url'] ?? '') . '" placeholder="https://… (страница программы)" style="max-width:200px;">'
. '<input type="text" name="branch_programs[' . $i . '][tags]" value="' . esc_attr($p['tags'] ?? '') . '" placeholder="Теги каталога (слова через пробел)" style="max-width:240px;">'
. '<button type="button" class="button branch-program-remove">—</button>'
. '</div>';
}
?>
</div>
<button type="button" class="button" id="branch-programs-add">+ Добавить программу</button>
<style>
.branch-program-row {
display:flex; align-items:center; gap:8px; margin-bottom:6px; flex-wrap:wrap;
}
.branch-program-row input[type="text"],
.branch-program-row input[type="url"] {
min-width:160px;
}
</style>
<script>
(function() {
var container = document.getElementById('branch-programs-repeater');
var addBtn = document.getElementById('branch-programs-add');
if (! addBtn) { return; }
addBtn.addEventListener('click', function() {
var idx = container.children.length;
var row = document.createElement('div');
row.className = 'branch-program-row';
row.innerHTML = '<input type="text" name="branch_programs[' + idx + '][name]" placeholder="Название (напр. Нейропсихолог)">'
+ '<input type="text" name="branch_programs[' + idx + '][price]" placeholder="Цена (напр. от 1 290 ₽)" style="max-width:170px;">'
+ '<input type="text" name="branch_programs[' + idx + '][price_note]" placeholder="Пояснение к цене" style="max-width:200px;">'
+ '<input type="url" name="branch_programs[' + idx + '][url]" placeholder="https://…" style="max-width:200px;">'
+ '<input type="text" name="branch_programs[' + idx + '][tags]" placeholder="Теги каталога" style="max-width:240px;">'
+ '<button type="button" class="button branch-program-remove">—</button>';
container.appendChild(row);
});
container.addEventListener('click', function(e) {
if (e.target.classList.contains('branch-program-remove')) {
e.target.parentElement.remove();
}
});
})();
</script>
</div>
</div>
<div class="branch-section collapsed">
<h2>🏢 ОРГАНИЗАЦИЯ</h2>
<div class="branch-section-body">
@ -4031,6 +4205,16 @@ function dekart_branch_settings_page()
<input type="text" name="branch_price_range" value="<?php echo esc_attr(get_option('branch_price_range', '$$')); ?>">
<p class="desc">Для микроразметки. Например: "$", "$$", "$$$".</p>
</div>
<div class="branch-field">
<label>Цена занятия (для страниц преподавателей)</label>
<input type="text" name="branch_price" value="<?php echo esc_attr(get_option('branch_price', '')); ?>" style="width:100%;max-width:220px">
<p class="desc">Стоимость занятия в мини-группе. Пример: «от 1 290 ₽». Выводится в блоке стоимости на странице преподавателя и в микроразметке Offer (price).<br><strong>Пусто</strong> на странице преподавателя покажется «Стоимость по запросу», в микроразметке price не выводится.</p>
</div>
<div class="branch-field">
<label>Пояснение к цене</label>
<input type="text" name="branch_price_note" value="<?php echo esc_attr(get_option('branch_price_note', 'за занятие 60 минут в мини-группе')); ?>" style="width:100%;max-width:420px">
<p class="desc">Подпись под ценой. Пример: «за занятие 60 минут в мини-группе». Пусто подпись скрыта.</p>
</div>
<div class="branch-field">
<label>Средний рейтинг школы (для метрик)</label>
<input type="text" name="branch_avg_rating" value="<?php echo esc_attr(get_option('branch_avg_rating', '4.9')); ?>" style="width:100%;max-width:120px">
@ -4149,6 +4333,32 @@ function dekart_branch_settings_page()
</div>
</div>
<div class="branch-section collapsed">
<h2>📊 СЧЕТЧИКИ</h2>
<div class="branch-section-body">
<div class="branch-field">
<label>Яндекс.Метрика (head)</label>
<textarea name="dekart_counter_yandex" rows="8" style="width:100%;max-width:700px;font-family:monospace;font-size:13px"><?php echo esc_textarea(get_option('dekart_counter_yandex', '')); ?></textarea>
<p class="desc">Код счётчика. Выводится в <code>&lt;head&gt;</code>.</p>
</div>
<div class="branch-field">
<label>Google Tag Manager (head)</label>
<textarea name="dekart_counter_gtm_head" rows="8" style="width:100%;max-width:700px;font-family:monospace;font-size:13px"><?php echo esc_textarea(get_option('dekart_counter_gtm_head', '')); ?></textarea>
<p class="desc">Основной код GTM. Выводится в <code>&lt;head&gt;</code>.</p>
</div>
<div class="branch-field">
<label>Google Tag Manager (body / noscript)</label>
<textarea name="dekart_counter_gtm_body" rows="5" style="width:100%;max-width:700px;font-family:monospace;font-size:13px"><?php echo esc_textarea(get_option('dekart_counter_gtm_body', '')); ?></textarea>
<p class="desc">Код <code>&lt;noscript&gt;</code>. Выводится сразу после открытия <code>&lt;body&gt;</code> через хук <code>wp_body_open</code>.</p>
</div>
<div class="branch-field">
<label>Произвольные скрипты (footer)</label>
<textarea name="dekart_counter_footer" rows="8" style="width:100%;max-width:700px;font-family:monospace;font-size:13px"><?php echo esc_textarea(get_option('dekart_counter_footer', '')); ?></textarea>
<p class="desc">Для чатов, пикселей и прочих скриптов. Выводится в <code>&lt;footer&gt;</code>.</p>
</div>
</div>
</div>
<script>
// Сворачивание/разворачивание блоков
document.querySelectorAll('.branch-section > h2').forEach(function(h2){
@ -4410,3 +4620,89 @@ function dekart_ajax_flush_cache()
*/
// ════════════════════════════════════════════
// Админ: вертикальные стабильные вкладки CF для преподавателя
// (нативный layout 'tabbed-vertical' + страховка от «списка вместо вкладок»)
// ════════════════════════════════════════════
function dekart_admin_teacher_tab_assets($hook)
{
if (! in_array($hook, array( 'post.php', 'post-new.php' ), true)) {
return;
}
$screen = get_current_screen();
if (! $screen || 'teacher' !== (string) $screen->post_type) {
return;
}
wp_enqueue_style(
'dekart-admin-teacher-tabs',
get_template_directory_uri() . '/assets/css/admin-teacher-tabs.css',
array( 'carbon-fields-core' ),
filemtime(get_template_directory() . '/assets/css/admin-teacher-tabs.css')
);
wp_enqueue_script(
'dekart-admin-teacher-tabs',
get_template_directory_uri() . '/assets/js/admin-teacher-tabs.js',
array(),
filemtime(get_template_directory() . '/assets/js/admin-teacher-tabs.js'),
true
);
}
/**
* Санитайзер каталога программ филиала (branch_programs).
* Каждая запись: name, price, price_note, url, tags.
*/
function dekart_sanitize_branch_programs($value)
{
if (! is_array($value)) {
return array();
}
$out = array();
foreach ($value as $item) {
if (! is_array($item)) {
continue;
}
$name = trim(sanitize_text_field((string) ( $item['name'] ?? '' )));
if ('' === $name) {
continue; // пустая строка — пропускаем
}
$out[] = array(
'name' => $name,
'price' => trim(sanitize_text_field((string) ( $item['price'] ?? '' ))),
'price_note' => trim(sanitize_text_field((string) ( $item['price_note'] ?? '' ))),
'url' => esc_url_raw(trim((string) ( $item['url'] ?? '' ))),
'tags' => trim(sanitize_text_field((string) ( $item['tags'] ?? '' ))),
);
}
return $out;
}
add_action('admin_enqueue_scripts', 'dekart_admin_teacher_tab_assets');
// ─────────────────────────────────────────────
// Админ: Настройки филиала — вертикальные вкладки
// ─────────────────────────────────────────────
function dekart_admin_branch_tab_assets($hook)
{
if (! isset($_GET['page']) || $_GET['page'] !== 'branch-settings') { // phpcs:ignore WordPress.Security.NonceVerification
return;
}
wp_enqueue_style(
'dekart-admin-branch-tabs',
get_template_directory_uri() . '/assets/css/admin-branch-tabs.css',
array(),
filemtime(get_template_directory() . '/assets/css/admin-branch-tabs.css')
);
wp_enqueue_script(
'dekart-admin-branch-tabs',
get_template_directory_uri() . '/assets/js/admin-branch-tabs.js',
array(),
filemtime(get_template_directory() . '/assets/js/admin-branch-tabs.js'),
true
);
}
add_action('admin_enqueue_scripts', 'dekart_admin_branch_tab_assets');

View File

@ -132,7 +132,7 @@ if (is_array($class_faq_items) && ! empty($class_faq_items)) {
}
}
if (! empty($graph)) : ?>
if (count($graph) > 0) : ?>
<script type="application/ld+json">
<?php echo wp_json_encode(array('@context' => 'https://schema.org', '@graph' => $graph), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>
</script>

File diff suppressed because it is too large Load Diff

View File

@ -16,12 +16,18 @@ if (! defined('ABSPATH')) {
$post_id = get_the_ID();
// ── Автор: Settings API → fallback ──
$author_name = get_option('branch_director_name', 'Елена Сергеевна Дегтярёва')
// ── Поддержка $args (переопределение автора, напр. для single-teacher) ──
$args = isset($args) && is_array($args) ? $args : array();
$args = wp_parse_args($args, array('author_name' => '', 'author_creds' => ''));
// ── Автор: $args → Settings API → fallback ──
$author_name = $args['author_name']
?: get_option('branch_director_name', 'Елена Сергеевна Дегтярёва')
?: carbon_get_post_meta($post_id, 'pksh_author_name')
?: get_option('branch_og_site_name', 'Частная школа Декарт');
$author_creds = get_option('branch_director_creds', 'директор частной школы «Декарт», педагогический стаж 18 лет');
$author_creds = $args['author_creds']
?: get_option('branch_director_creds', 'директор частной школы «Декарт», педагогический стаж 18 лет');
// ── Даты (автоматически из WP) ──
$publish_date = $post_id ? get_the_date('c', $post_id) : '';

View File

@ -79,7 +79,7 @@ if (! $hero_src) {
<?php endif; ?>
</div>
<?php if (! empty($hero_stats)) : ?>
<?php if (count($hero_stats) > 0) : ?>
<ul class="class-hero__stats">
<?php foreach ($hero_stats as $stat) :
$num = isset($stat['stat_number']) ? trim($stat['stat_number']) : '';

View File

@ -110,7 +110,7 @@ if (! empty($extra_msgs) && is_array($extra_msgs)) {
<p class="ds-hero__subtitle"><strong>Небольшие классы до 15 человек</strong>, опытные педагоги и углублённый английский с 1&#8209;го класса. Учебные занятия — в первую половину дня (<strong>до 13:00</strong>). После обеда — продлёнка с досуговыми занятиями, прогулками и помощью с домашними заданиями (<strong>до 19:00</strong>).<br><br>Можно забрать ребёнка после уроков или оставить на продлёнку. В продлёнку входят обед, полдник, помощь с ДЗ и кружки. Индивидуальный подход к каждому ученику. Открыт набор на <span class="ds-hero__year"><?php echo esc_html($school_year); ?></span> учебный год.</p>
<?php if (! empty($floats)) : ?>
<?php if (count($floats) > 0) : ?>
<div class="ds-hero__mobile-usps">
<?php foreach ($floats as $float) : ?>
<span class="ds-hero__mobile-usp">

View File

@ -1,39 +1,125 @@
<?php
/**
* Секция отзывов на главной странице
* Показывает заголовок + 3 отзыва из CF reviews_cards (страница 91 /otzyvy/) + ссылка на все
* Стиль карточек как на /otzyvy/
* Секция отзывов «как на главной» (bento, карточки reviews-front__*).
*
* Без $args['teacher'] берёт 3 последних отзыва из CF reviews_cards (стр. 91 /otzyvy/).
* С $args['teacher'] показывает последние 3 отзыва CPT reviews, привязанных
* к преподавателю (meta review_teacher + review_type school/primary). Если у
* преподавателя отзывов нет секция не выводится.
*
* @package Dekart
* @since 1.0.0
*/
if (! defined('ABSPATH')) {
exit;
}
$reviews_page_id = 91;
$reviews_cards = carbon_get_post_meta($reviews_page_id, 'reviews_cards');
$has_reviews = ! empty($reviews_cards) && is_array($reviews_cards);
$teacher_filter = ! empty($args['teacher']) ? (int) $args['teacher'] : 0;
// Берём 3 последних (в CF они в обратном порядке, но на всякий случай переворачиваем)
if ($has_reviews) {
$reviews_cards = array_reverse($reviews_cards);
$reviews_cards = array_slice($reviews_cards, 0, 3);
$head_label = 'Отзывы';
$head_title = 'Узнайте, что говорят о нас родители и ученики';
$head_desc = 'Более 190 оценок на внешних площадках — средний балл 4,9 из 5';
if ($teacher_filter) {
// ── Отзывы о конкретном преподавателе (CPT reviews) ──
$teacher_query = new WP_Query(array(
'post_type' => 'reviews',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'DESC',
'fields' => 'ids',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'review_teacher',
'value' => $teacher_filter,
'compare' => 'LIKE',
),
array(
'key' => 'review_type',
'value' => array( 'school', 'primary' ),
'compare' => 'IN',
),
),
));
$teacher_ids = $teacher_query->posts ?: array();
$has_reviews = ! empty($teacher_ids);
$reviews_cards = array();
if ($has_reviews) {
$ratings = array();
foreach ($teacher_ids as $rid) {
$star = (int) carbon_get_post_meta($rid, 'review_star');
if ($star >= 1 && $star <= 5) {
$ratings[] = $star;
}
}
// Последние 3 отзыва (как на главной).
foreach (array_slice($teacher_ids, 0, 3) as $rid) {
$avatar_id = carbon_get_post_meta($rid, 'review_avatar');
$photo_url = '';
if ($avatar_id && is_numeric($avatar_id)) {
$photo_url = (string) wp_get_attachment_image_url((int) $avatar_id, 'full');
}
$reviews_cards[] = array(
'review_author' => get_the_title($rid),
'review_rating' => (int) ( carbon_get_post_meta($rid, 'review_star') ?: 5 ),
'review_text' => (string) carbon_get_post_meta($rid, 'review_text'),
'review_date' => get_the_date('d.m.Y', $rid),
'review_photo' => $photo_url,
);
}
$total_cnt = count($teacher_ids);
if ($ratings) {
$avg = number_format(array_sum($ratings) / count($ratings), 1, '.', ' ');
// Русские формы множественного числа (1 отзыв / 2 отзыва / 5 отзывов).
$n10 = $total_cnt % 10;
$n100 = $total_cnt % 100;
if ($n10 === 1 && $n100 !== 11) {
$word = 'проверенный отзыв';
} elseif (in_array($n10, array( 2, 3, 4 ), true) && ! in_array($n100, array( 12, 13, 14 ), true)) {
$word = 'проверенных отзыва';
} else {
$word = 'проверенных отзывов';
}
$head_desc = 'Средняя оценка ' . $avg . ' из 5 · ' . $total_cnt . ' ' . $word;
} else {
$head_desc = 'Реальные отзывы родителей и учеников о занятиях';
}
$head_title = 'Отзывы о преподавателе';
}
wp_reset_postdata();
} else {
// ── Отзывы школы: 3 карточки из CF страницы /otzyvy/ (по умолчанию) ──
$reviews_page_id = 91;
$reviews_cards = carbon_get_post_meta($reviews_page_id, 'reviews_cards');
$has_reviews = ! empty($reviews_cards) && is_array($reviews_cards);
if ($has_reviews) {
$reviews_cards = array_slice(array_reverse($reviews_cards), 0, 3);
}
}
if (! $has_reviews) {
return;
}
?>
<section class="reviews-front-section reveal" id="reviews" aria-label="Отзывы">
<div class="container">
<div class="section-header--centered">
<span class="bento-label">Отзывы</span>
<h2 class="bento-h2">Узнайте, что говорят о нас родители и ученики</h2>
<p class="bento-p">Более 190 оценок на внешних площадках средний балл 4,9 из 5</p>
<span class="bento-label"><?php echo esc_html($head_label); ?></span>
<h2 class="bento-h2"><?php echo esc_html($head_title); ?></h2>
<p class="bento-p"><?php echo esc_html($head_desc); ?></p>
</div>
<?php if ($has_reviews) : ?>
<div class="reviews-front__scroll-hint" aria-hidden="true">👆 Листайте отзывы </div>
<div class="reviews-front__wrap">
<div class="reviews-front__grid">
@ -91,8 +177,6 @@ if ($has_reviews) {
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php wp_reset_postdata(); ?>
<div class="reviews-front__all-link">
<a href="/otzyvy/" class="reviews-link__btn">Читать все отзывы </a>

View File

@ -130,7 +130,7 @@ if (! empty($extra_msgs) && is_array($extra_msgs)) {
?>
<p class="ds-hero__subtitle"><?php echo wp_kses_post($hero_subtitle); ?><br><br>Открыт набор на <span class="ds-hero__year"><?php echo esc_html($school_year); ?></span> учебный год в 14 классы.</p>
<?php if (! empty($floats)) : ?>
<?php if (count($floats) > 0) : ?>
<div class="ds-hero__mobile-usps">
<?php foreach ($floats as $float) : ?>
<span class="ds-hero__mobile-usp">

View File

@ -90,10 +90,10 @@ $teachers_query = new WP_Query($query_args);
$name_first = $name_parts[0] ?? $t_name;
$name_rest = $name_parts[1] ?? '';
// Description truncation.
// Description truncation (~3 строки карточки; поле: до 140 знаков).
$t_about_clean = $t_about ? wp_strip_all_tags($t_about) : '';
if (mb_strlen($t_about_clean) > 200) {
$t_about_clean = mb_substr($t_about_clean, 0, 200) . '…';
if (mb_strlen($t_about_clean) > 150) {
$t_about_clean = mb_substr($t_about_clean, 0, 150) . '…';
}
?>
<div class="teachers-cards__item">

View File

@ -77,6 +77,11 @@ $current_year = (int) current_time('Y');
<div class="teachers-card__direction"><?php echo esc_html($direction); ?></div>
<?php endif; ?>
<?php $t_desc = trim((string) carbon_get_post_meta($tid, 'teacher_description')); ?>
<?php if ($t_desc !== '') : ?>
<p class="teachers-card__about"><?php echo esc_html(wp_strip_all_tags($t_desc)); ?></p>
<?php endif; ?>
<?php if ($tenure) : ?>
<div class="teachers-card__tenure">Стаж: <strong><?php echo esc_html($tenure); ?></strong> лет</div>
<?php endif; ?>