2026-09-13 single-teacher.php

This commit is contained in:
Dekart Deploy Bot 2026-09-13 22:10:59 +00:00
parent 1efcbb4616
commit b47113ab98
6 changed files with 340 additions and 184 deletions

View File

@ -5019,6 +5019,35 @@ footer a:hover,
.form-wrapper .b24-form-container {
width: 100%;
}
.form-wrapper__offer {
text-align: center;
margin-bottom: 20px;
}
.form-wrapper__badge {
display: inline-block;
padding: 4px 12px;
margin-bottom: 8px;
border-radius: 999px;
background: var(--main-yellow, #FFD43E);
color: #1F2937;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.form-wrapper__title {
margin: 0 0 6px;
font-size: 1.25rem;
line-height: 1.3;
font-weight: 700;
color: #1F2937;
}
.form-wrapper__hint {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: #64748B;
}
.form-wrapper .b24-form {
border-radius: 12px;
overflow: hidden;

View File

@ -459,33 +459,6 @@
color: var(--color-text-muted, #475569);
margin-bottom: 20px;
}
.teacher-single__subject-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.teacher-single__subject-list li {
padding: 14px 16px;
border-radius: 14px;
background: var(--color-bg, #FFFFFF);
border: 1px solid var(--color-border, #E2E8F0);
}
.teacher-single__subject-list strong {
display: block;
margin-bottom: 4px;
}
.teacher-single__subject-list span {
display: inline-block;
margin: 2px 6px 0 0;
padding: 4px 10px;
border-radius: var(--radius-pill, 100px);
background: var(--color-primary-light, #EFF6FF);
color: var(--color-primary, #2563EB);
font-size: 0.8125rem;
}
.teacher-single__cert-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
@ -727,6 +700,12 @@
.teacher-single__timeline-item {
padding-bottom: 16px;
}
.teacher-single__hero .ds-hero__inner {
gap: 28px;
}
.teacher-single__hero-facts {
margin: var(--space-4, 24px) 0;
}
}
@media (prefers-reduced-motion: reduce) {
@ -791,10 +770,9 @@
box-shadow: var(--shadow-md, 0 8px 24px rgba(15, 23, 42, 0.1));
}
/* Микро-плитки: радиус 16 (иконки, серты, subject-list, FAQ, trust-shield) */
/* Микро-плитки: радиус 16 (иконки, серты, FAQ, trust-shield) */
.teacher-single__segment-icon,
.teacher-single__cert,
.teacher-single__subject-list li,
.teacher-single__trust-shield,
.teacher-single__faq-item {
border-radius: 16px;
@ -1170,5 +1148,70 @@
flex-direction: column;
align-items: flex-start;
}
.teacher-single__course-link {
padding: 8px 0;
min-height: 44px;
line-height: 1.4;
}
}
/* ── Соц-доказательство (после программ) ── */
.teacher-single__social-proof {
padding: 24px 0;
}
.teacher-single__social-proof-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 20px 48px;
padding: 20px 28px;
border: 1px solid var(--color-border, rgba(15, 23, 42, 0.08));
border-radius: 20px;
background: rgba(15, 23, 42, 0.03);
}
.teacher-single__sp-item {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
max-width: 420px;
}
.teacher-single__sp-stars {
display: inline-flex;
gap: 2px;
color: rgba(15, 23, 42, 0.25);
}
.teacher-single__sp-stars svg.is-filled {
color: #F59E0B;
}
.teacher-single__sp-num {
font-family: var(--font-display, inherit);
font-size: 1.375rem;
font-weight: 700;
line-height: 1.2;
color: var(--color-text, #0F172A);
}
.teacher-single__sp-text {
font-size: 0.875rem;
line-height: 1.4;
color: var(--color-text-muted, #64748B);
}
.teacher-single__sp-link {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-primary, #006DB7);
text-decoration: none;
}
.teacher-single__sp-link:hover {
text-decoration: underline;
}
@media (max-width: 639px) {
.teacher-single__social-proof-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 18px 20px;
}
}

View File

@ -25,13 +25,20 @@
});
}
// ── Sticky CTA (mobile): показать после hero ──
// ── Sticky CTA (mobile): показать после hero, скрыть у финального CTA ──
var sticky = document.querySelector('.teacher-single__sticky');
if (sticky) {
var hero = document.querySelector('.teacher-single__hero');
var finalCta = document.querySelector('.teacher-single__final');
if (hero) {
var showSticky = function () {
sticky.hidden = window.scrollY < hero.offsetHeight - 80;
var afterHero = window.scrollY >= hero.offsetHeight - 80;
var finalVisible = false;
if (finalCta) {
var fr = finalCta.getBoundingClientRect();
finalVisible = fr.top < window.innerHeight && fr.bottom > 0;
}
sticky.hidden = !afterHero || finalVisible;
};
showSticky();
var ticking = false;

View File

@ -96,6 +96,11 @@ if (! empty($b24_raw)) {
alt="Закрыть"
loading="lazy">
</button>
<div class="form-wrapper__offer">
<span class="form-wrapper__badge">Бесплатно</span>
<p class="form-wrapper__title">Записаться на бесплатную диагностику</p>
<p class="form-wrapper__hint">Перезвоним за 15 минут · Без обязательств</p>
</div>
<div class="b24-form-container"></div>
</div>
</div>

View File

@ -3253,6 +3253,43 @@ function dekart_teacher_review_count(int $teacher_id): int
return isset($map[ $teacher_id ]) ? (int) $map[ $teacher_id ] : 0;
}
/**
* Средняя оценка преподавателя по реальным отзывам CPT (поле review_star).
* Один SQL-агрегат вместо перебора всех отзывов carbon_get_post_meta на каждый id
* (500+ отзывов = 500 запросов, object-cache может быть отключён).
* Возвращает 0.0, если валидных оценок нет.
*/
function dekart_teacher_review_avg(int $teacher_id): float
{
global $wpdb;
static $cache = array();
if (isset($cache[ $teacher_id ])) {
return $cache[ $teacher_id ];
}
$sql = $wpdb->prepare(
"SELECT AVG(CAST(pm.meta_value AS DECIMAL(10, 2)))
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
INNER JOIN {$wpdb->postmeta} pmt
ON pmt.post_id = pm.post_id
AND pmt.meta_key = %s
AND pmt.meta_value = %s
WHERE pm.meta_key = %s
AND pm.meta_value REGEXP '^[1-5]$'
AND p.post_type = 'reviews'
AND p.post_status = 'publish'",
'_review_teacher|||0|id',
(string) $teacher_id,
'_review_star'
);
$cache[ $teacher_id ] = (float) $wpdb->get_var($sql);
return $cache[ $teacher_id ];
}
/**
* OG-изображение 1200×630 для страницы «Преподаватели».
* Глобальный дефолт Yoast (banner-dekart-small.webp 355×250) даёт битый сниппет.

View File

@ -1,15 +1,9 @@
<?php
/**
* Личная страница преподавателя (Bento 2026) конверсионная воронка.
* Личная страница преподавателя (Bento 2026).
*
* Воронка: Meta Hero (+2 CTA + риск) Цифры (человек+система)
* Дефицит Сегменты (+4-й под спойлером) Личная история + «Как проходит
* занятие» Кейсы (табы 9/11) FAQ (формат) Отзывы Доверие/лицензия
* Другие преподаватели финальный CTA Карта. Sticky-CTA на мобильном.
*
* Данные из CF карточки преподавателя (деплой через контент).
* Общие части переиспользуются: template-parts/reviews, template-parts/teachers-cards,
* template-parts/contacts (как на главной).
* Данные из CF карточки преподавателя. Общие части: reviews-link,
* teachers-cards, contacts, author-eaat. Sticky-CTA на мобильном.
*
* @package Dekart
*/
@ -18,6 +12,20 @@ if (! defined('ABSPATH')) {
exit;
}
// og:image:alt из featured image (паттерн page-camp.php)
add_action('wp_head', function () {
if (! is_singular('teacher')) {
return;
}
$thumb_id = get_post_thumbnail_id(get_queried_object_id());
if ($thumb_id) {
$alt = get_post_meta($thumb_id, '_wp_attachment_image_alt', true);
if ($alt) {
echo '<meta property="og:image:alt" content="' . esc_attr($alt) . '" />' . "\n";
}
}
}, 1);
get_header();
$tid = (int) get_the_ID();
@ -28,10 +36,28 @@ $branch_phone = get_option('branch_phone') ?: carbon_get_theme_option('site_pho
$branch_city = get_option('branch_city') ?: carbon_get_theme_option('site_city', 'Щёлково');
$branch_city_prep = get_option('branch_city_prep', 'в Щёлково') ?: 'в Щёлково';
$branch_name = get_option('branch_name') ?: carbon_get_theme_option('site_name', 'Декарт');
$phone_clean = $branch_phone ? preg_replace('/[\s\-\\(\)]/', '', $branch_phone) : '';
$wa_on = (bool) get_option('branch_messenger_whatsapp_on', 1);
$wa_url = $wa_on ? get_option('branch_messenger_whatsapp') : '';
$wa_url = $wa_url ?: ( $phone_clean ? 'https://wa.me/' . $phone_clean : '' );
// Тел.: цифры и «+». WhatsApp: 8-XXXXXXXXXX → 7-XXXXXXXXXX, без номера — без ссылки.
$phone_clean = $branch_phone ? preg_replace('/[^0-9+]/', '', $branch_phone) : '';
$wa_on = (bool) get_option('branch_messenger_whatsapp_on', 1);
$wa_number = '';
if ($wa_on) {
$wa_number = trim((string) get_option('branch_messenger_whatsapp'));
if (false !== strpos($wa_number, 'wa.me/')) {
$wa_number = substr($wa_number, strpos($wa_number, 'wa.me/') + 6);
}
$wa_number = preg_replace('/[^0-9]/', '', $wa_number);
if ('' !== $wa_number && '8' === $wa_number[0] && 11 === strlen($wa_number)) {
$wa_number = '7' . substr($wa_number, 1);
}
}
// Фолбэк: телефон филиала.
if ('' === $wa_number && $phone_clean) {
$wa_number = preg_replace('/[^0-9]/', '', $phone_clean);
if ('8' === $wa_number[0] && 11 === strlen($wa_number)) {
$wa_number = '7' . substr($wa_number, 1);
}
}
$wa_url = ('' !== $wa_number) ? 'https://wa.me/' . $wa_number : '';
// ─── Профиль преподавателя (CF) ───
$direction = carbon_get_post_meta($tid, 'teacher_direction');
@ -45,11 +71,24 @@ if ($start_year && absint($start_year) > 1900) {
$tenure = max(0, (int) current_time('Y') - (int) $start_year);
}
$avatar_id = carbon_get_post_meta($tid, 'teacher_avatar');
$avatar_url = $avatar_id ? wp_get_attachment_url($avatar_id) : '';
$avatar_url = $avatar_id ? wp_get_attachment_url($avatar_id) : ''; // Schema Person.image
$avatar_alt = $avatar_id ? get_post_meta($avatar_id, '_wp_attachment_image_alt', true) : '';
if (empty($avatar_alt)) {
$avatar_alt = 'Преподаватель ' . $full_name . ' — школа «' . $branch_name . '» ' . $branch_city;
}
// image() = проверка наличия + готовый HTML, без отдельного url-запроса.
$avatar_img = $avatar_id ? wp_get_attachment_image(
(int) $avatar_id,
array( 600, 600 ),
false,
array(
'class' => 'ds-hero__photo',
'alt' => $avatar_alt,
'loading' => 'eager',
'decoding' => 'async',
'fetchpriority' => 'high', // LCP-изображение
)
) : '';
$avg_score = carbon_get_post_meta($tid, 'teacher_avg_score');
$seats_left = carbon_get_post_meta($tid, 'teacher_seats_left');
@ -58,8 +97,6 @@ $avg_label = carbon_get_post_meta($tid, 'teacher_avg_label');
if (empty($avg_label)) {
$avg_label = 'Средний балл учеников';
}
$rating_label = 'Рейтинг преподавателя';
$group_label = 'человек в мини-группе — внимание как на репетиторе';
$exam_label = carbon_get_post_meta($tid, 'teacher_exam_label') ?: 'нового набора';
$cta_primary = carbon_get_post_meta($tid, 'teacher_cta_primary') ?: 'Записаться на диагностику';
$sched_label = carbon_get_post_meta($tid, 'teacher_schedule_label') ?: 'Посмотреть расписание групп';
@ -68,7 +105,7 @@ $risk_note = carbon_get_post_meta($tid, 'teacher_risk_note');
if (empty($risk_note)) {
$risk_note = 'Перезвоним за 15 минут · Без обязательств';
}
$school_num = carbon_get_post_meta($tid, 'teacher_school_num') ?: '100%';
$school_num = carbon_get_post_meta($tid, 'teacher_school_num');
$school_label = carbon_get_post_meta($tid, 'teacher_school_label');
if (empty($school_label)) {
$school_label = 'учеников продолжают обучение на следующий учебный год';
@ -83,7 +120,7 @@ $about_text = carbon_get_post_meta($tid, 'teacher_about');
$education = carbon_get_post_meta($tid, 'teacher_education');
$add_education = carbon_get_post_meta($tid, 'teacher_add_education');
$video_html = carbon_get_post_meta($tid, 'teacher_video');
// Если в поле ссылка (не iframe) — собираем iframe VK/YouTube автоматически. Пусто → блок не выводится.
// Ссылка VK/YouTube → iframe. Пусто → блок скрыт.
if ($video_html && 0 !== strpos(ltrim($video_html), '<')) {
$video_url = trim($video_html);
if (preg_match('~vk\.com/video(-?\d+)_(\d+)~', $video_url, $vm)) {
@ -97,10 +134,10 @@ if (empty($license)) {
$license = 'Частная школа «' . $branch_name . '» работает по лицензии на образовательную деятельность (бессрочная). Копия лицензии и реквизиты — по запросу у администратора.';
}
// Отзывы (счётчик реальных, как везде на сайте)
// Счётчик реальных отзывов.
$review_count = function_exists('dekart_teacher_review_count') ? dekart_teacher_review_count($tid) : 0;
// Подзаголовок hero: редактируется в админке (вкладка Hero). Пусто — авто-сборка.
// Подзаголовок hero — из админки (вкладка Hero); пусто — авто-сборка.
$hero_subtitle = trim((string) carbon_get_post_meta($tid, 'teacher_hero_subtitle'));
if ('' === $hero_subtitle) {
$hero_subtitle = ( $subj ? $subj : 'Обучение' ) . ' — мини-группы ' . $group_size . ' чел. · ' . $exam_label . ' · школа «' . $branch_name . '» ' . $branch_city_prep;
@ -160,17 +197,20 @@ foreach ($legacy_questions as $lq) {
}
// ─── Дни до экзамена (полоса дефицита) ───
// «Сегодня» = 0, «завтра» = 1; прошедшая дата → блок скрыт.
$days_left = '';
if ($exam_date && preg_match('/^\d{4}-\d{2}-\d{2}$/', $exam_date)) {
$diff = (int) (( strtotime($exam_date) - current_time('timestamp') ) / DAY_IN_SECONDS);
$tz = wp_timezone();
$exam_ts = (int) ( new DateTime($exam_date, $tz) )->format('U');
$today_ts = (int) ( new DateTime('now', $tz) )->setTime(0, 0)->format('U');
$diff = (int) round( ( $exam_ts - $today_ts ) / DAY_IN_SECONDS );
if ($diff >= 0) {
$days_left = (string) $diff;
}
}
// ─── Микроразметка (Schema.org JSON-LD, единый @graph) ───
// Данные — только из CF преподавателя и настроек филиала (branch_*/site_*).
// Пустые свойства в схему не выводятся (Google ругается на пустые обязательные поля).
// ─── Микроразметка (единый @graph) ───
// Пустые свойства не выводим (Google ругается на пустые обязательные поля).
$teachers_page = get_page_by_path('our-teachers');
$page_url = get_permalink($tid);
@ -205,7 +245,7 @@ if ($branch_email) {
$org['email'] = $branch_email;
}
// Адрес филиала — из опций (branch_*), фолбэк — CF theme options (site_*)
// Адрес филиала: branch_* → фолбэк site_*.
$org_addr = array();
$org_street = get_option('branch_address') ?: carbon_get_theme_option('site_address');
$org_city = get_option('branch_city') ?: carbon_get_theme_option('site_city');
@ -220,7 +260,7 @@ if (! empty($org_addr)) {
$org['address'] = $org_addr;
}
// Лицензия (hasCredential) — из настроек филиала
// Лицензия — из настроек филиала.
$org_license = get_option('branch_license') ?: carbon_get_theme_option('site_license');
if (! empty($org_license)) {
$org['hasCredential'] = array(
@ -230,8 +270,7 @@ if (! empty($org_license)) {
}
// ── Person (преподаватель) ──
// Каталог программ филиала (единый для всего сайта): название → ссылка.
// Цена берётся из этого каталога только для микроразметки Course.offers (не выводится на странице).
// Каталог программ филиала: название → url/price (цена — только для Course.offers в микроразметке).
$branch_programs_by_name = array();
foreach ((array) get_option('branch_programs', array()) as $bp) {
$pn = trim((string) ( $bp['name'] ?? '' ));
@ -243,9 +282,7 @@ foreach ((array) get_option('branch_programs', array()) as $bp) {
}
}
// Предметы: title → name курса (выбор из каталога филиала),
// teacher_subjects_topics → чипы. URL — из каталога филиала (fallback: legacy url преподавателя);
// цена — из того же каталога, но используется только микроразметкой Course.offers.
// Предметы: title → курс; topics → чипы. URL/цена — из каталога филиала (fallback — CF преподавателя).
$teacher_subjects = (array) carbon_get_post_meta($tid, 'teacher_subjects');
$subject_items = array(); // [title => ['name', 'url', 'price', 'topics[]']] без дубликатов
foreach ($teacher_subjects as $ts) {
@ -283,11 +320,13 @@ $geo_walk = get_option('branch_logistics_walk');
$geo_parking = get_option('branch_logistics_parking');
$geo_schools = get_option('branch_logistics_schools');
// ─── Результаты учеников (CF; пусто — сборка из метрик преподавателя) ───
$results = (array) carbon_get_post_meta($tid, 'teacher_results');
if (empty($results) || ! is_array($results)) {
$results = array();
if ('' !== $school_num) { $results[] = array( 'result_title' => $school_num, 'result_text' => $school_label ); }
// ─── Результаты учеников: CF или авто-сборка ───
$cf_results = carbon_get_post_meta($tid, 'teacher_results');
$results = array();
if (is_array($cf_results) && ! empty($cf_results)) {
$results = array_values($cf_results);
} else {
if (! empty($school_num)) { $results[] = array( 'result_title' => (string) $school_num, 'result_text' => $school_label ); }
if ($avg_score) { $results[] = array( 'result_title' => $avg_score, 'result_text' => $avg_label ); }
if ($tenure) { $results[] = array( 'result_title' => $tenure . ' лет', 'result_text' => 'педагогический стаж преподавателя' ); }
if ($review_count > 0) { $results[] = array( 'result_title' => (string) $review_count, 'result_text' => 'реальных отзывов родителей о преподавателе' ); }
@ -317,69 +356,23 @@ if ('' !== $diploma) {
$person['alumniOf'] = array('@type' => 'CollegeOrUniversity', 'name' => $diploma);
}
// AggregateRating — только реальные данные. Размещается на Course, а не на Person
// (свойство aggregateRating невалидно для типа Person в schema.org).
$course_rating = array();
if ($rating_num > 0 && $review_count > 0) {
$course_rating = array(
'@type' => 'AggregateRating',
'ratingValue' => number_format($rating_num, 1),
'bestRating' => '5',
'ratingCount' => $review_count,
);
}
// ── Рейтинг преподавателя ──
// Среднее реальных отзывов (SQL-агрегат); CF teacher_rating — фолбэк, когда отзывов нет.
$sp_avg_num = function_exists('dekart_teacher_review_avg')
? (float) dekart_teacher_review_avg($tid)
: 0.0;
$sp_avg = $sp_avg_num > 0 ? number_format($sp_avg_num, 1, '.', '') : '';
$rating_num = $sp_avg_num > 0 ? $sp_avg_num : ( is_numeric($rating) ? (float) $rating : 0.0 );
// Review — реальные отзывы из CPT «reviews», связанные с преподавателем. Не выдумываем.
// Размещаются на Course (свойство review невалидно для Person).
$review_pool = get_posts(array(
'post_type' => 'reviews',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
));
$reviews = array();
foreach ($review_pool as $rid) {
$link = carbon_get_post_meta((int) $rid, 'review_teacher');
if (! is_array($link) || empty($link[0]['id']) || (int) $link[0]['id'] !== $tid) {
continue;
}
$r_author = trim((string) get_the_title((int) $rid));
$r_body = trim((string) carbon_get_post_meta((int) $rid, 'review_text'));
$r_star = (int) ( carbon_get_post_meta((int) $rid, 'review_star') ?: 5 );
// AggregateRating не размечаем: невалиден для Person в schema.org, Google не даёт
// по нему rich results. Средний балл — только UI; в Schema — отдельные Review с reviewRating.
$review = array('@type' => 'Review');
if ('' !== $r_author) {
$review['author'] = array('@type' => 'Person', 'name' => $r_author);
}
$pub = get_the_date('c', (int) $rid);
if ($pub) {
$review['datePublished'] = $pub;
}
if ($r_star > 0) {
$review['reviewRating'] = array(
'@type' => 'Rating',
'ratingValue' => (string) $r_star,
'bestRating' => '5',
);
}
if ('' !== $r_body) {
$review['reviewBody'] = $r_body;
}
$reviews[] = $review;
if (count($reviews) >= 3) {
break;
}
}
if (! empty($reviews)) {
// $reviews переносятся на каждый Course ниже (в блоке сборки Course).
}
// Review не размечаем: Google не поддерживает Person среди itemReviewed для review
// rich results. Отзывы остаются в видимом HTML (reviews-link) — там их место.
// ── Course — по каждому предмету из CF (комбинация предмет + формат мини-группы) ──
// Валидная структура: Course → hasCourseInstance (CourseInstance) с courseMode/instructor;
// aggregateRating и review живут на Course (не на Person).
// Person и Org объявляются один раз (@id), в курсах — только ссылки {"@id": ...}.
$build_course = function ( $name, $cid, $availability = null, $description = '', $price = '' ) use ( $page_url, $org_id, $person_id, $group_size, $full_name, $course_rating, $reviews, $branch_price ) {
// ── Course — по каждому предмету (предмет + мини-группа) ──
// Person и Org объявляются один раз (@id); в курсах — ссылки.
$build_course = function ( $name, $cid, $availability = null, $description = '', $price = '' ) use ( $page_url, $org_id, $person_id, $group_size, $full_name, $branch_price ) {
$course = array(
'@type' => 'Course',
'@id' => $page_url . '#course-' . $cid,
@ -398,28 +391,30 @@ $build_course = function ( $name, $cid, $availability = null, $description = '',
'category' => 'Мини-группа 2' . $group_size . ' человек',
),
);
// Цена (только микроразметка Course.offers): из каталога программы (per-program) → fallback branch_price.
// Цена — только микроразметка: из каталога программы → фолбэк branch_price;
// Schema требует число — извлекаем из строки вида «от 1 290 ₽».
$offer_price = ( '' !== (string) $price ) ? $price : $branch_price;
if ('' !== (string) $offer_price) {
$course['offers']['price'] = $offer_price;
$offer_price_norm = preg_replace('/\s+/u', '', (string) $offer_price);
if (preg_match('/\d+(?:[.,]\d+)?/', $offer_price_norm, $pm)) {
$course['offers']['price'] = str_replace(',', '.', $pm[0]);
$course['offers']['priceCurrency'] = 'RUB';
}
if (null !== $availability) {
$course['offers']['availability'] = $availability;
}
if (! empty($course_rating)) {
$course['aggregateRating'] = $course_rating;
}
if (! empty($reviews)) {
$course['review'] = $reviews;
}
return $course;
};
$courses = array();
if (! empty($subject_names)) {
foreach ($subject_names as $i => $cn) {
$availability = ( $seats_left && (int) $seats_left > 0 ) ? 'https://schema.org/LimitedAvailability' : 'https://schema.org/InStock';
// Availability: >0 → Limited, =0 → SoldOut, пусто → не указываем.
$availability = null;
if ('' !== (string) $seats_left) {
$availability = ( (int) $seats_left > 0 )
? 'https://schema.org/LimitedAvailability'
: 'https://schema.org/SoldOut';
}
$courses[] = $build_course($cn, $i + 1, $availability, '', $subject_items[$cn]['price']);
}
} else {
@ -480,7 +475,6 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<meta itemprop="position" content="1">
</span>
<span aria-hidden="true">/</span>
<?php $teachers_page = get_page_by_path('our-teachers'); ?>
<?php if ($teachers_page) : ?>
<span itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="<?php echo esc_url(get_permalink($teachers_page)); ?>"><span itemprop="name">Преподаватели</span></a>
@ -543,10 +537,10 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
</div>
<div class="ds-hero__visual teacher-single__visual">
<?php if ($avatar_url) : ?>
<?php if ($avatar_img) : ?>
<div class="ds-hero__photo-wrap">
<div class="ds-hero__photo-frame teacher-single__photo-frame">
<img class="ds-hero__photo" src="<?php echo esc_url($avatar_url); ?>" alt="<?php echo esc_attr($avatar_alt); ?>" width="600" height="600" loading="eager" decoding="async">
<?php echo $avatar_img; ?>
</div>
<div class="ds-hero__photo-ring" aria-hidden="true"></div>
</div>
@ -558,9 +552,8 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
</section>
<?php
// ─────────────────── ЦИФРЫ (человек + система) ───────────────────
// Карточки цифр — ТОЛЬКО из CF complex teacher_metrics (цифра + подпись), можно добавлять/удалять.
// Пустые карточки пропускаются. Нет карточек — блок скрыт (резервных полей нет).
// ─────────────────── ЦИФРЫ ───────────────────
// Карточки — только из CF complex teacher_metrics; пусто — секция скрыта.
$metrics = array();
$metric_cards = (array) carbon_get_post_meta($tid, 'teacher_metrics');
if (! empty($metric_cards) && is_array($metric_cards)) {
@ -618,9 +611,8 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<?php if (! empty($subject_items)) : ?>
<?php
// ─────────────────── ПРОГРАММЫ И НАПРАВЛЕНИЯ (H2-секции курсов) ───────────────────
// Каждый курс — отдельная H2-секция «{Предмет} {в городе}» с якорем #course-N
// (якорь совпадает с @id в микроразметке Course). Цена на странице не выводится.
// ─────────────────── ПРОГРАММЫ (H2-секция на курс) ───────────────────
// Якорь #course-N совпадает с @id в микроразметке. Цена на странице не выводится.
?>
<section class="teacher-single__courses section-bg reveal" data-bg="teacher-courses" aria-labelledby="teacher-courses-title">
<div class="container">
@ -662,6 +654,44 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
</section>
<?php endif; ?>
<?php
// ─────────────────── СОЦИАЛЬНОЕ ДОКАЗАТЕЛЬСТВО ───────────────────
// Рейтинг преподавателя — $sp_avg (рассчитан выше), школы — из опций филиала.
$branch_avg = (string) get_option('branch_avg_rating', '');
$branch_cnt = (string) get_option('branch_ext_review_count', '');
if ('' !== $sp_avg || $branch_avg || $branch_cnt) :
?>
<section class="teacher-single__social-proof section-bg reveal" data-bg="teacher-social-proof" aria-label="Отзывы родителей">
<div class="container">
<div class="teacher-single__social-proof-inner">
<?php if ('' !== $sp_avg) : ?>
<div class="teacher-single__sp-item">
<span class="teacher-single__sp-stars" aria-hidden="true">
<?php for ($sp_i = 1; $sp_i <= 5; $sp_i++) : ?>
<svg class="<?php echo $sp_i <= (int) round((float) $sp_avg) ? 'is-filled' : ''; ?>" width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path d="M10 1l2.39 4.84 5.34.78-3.87 3.77.92 5.34L10 13.27l-4.78 2.51.92-5.34L2.27 6.6l5.34-.78L10 1z"/></svg>
<?php endfor; ?>
</span>
<strong class="teacher-single__sp-num"><?php echo esc_html($sp_avg); ?></strong>
<span class="teacher-single__sp-text">оценка преподавателя по отзывам родителей</span>
<?php if ($review_count > 0) : ?>
<a class="teacher-single__sp-link" href="#reviews"><?php echo esc_html($review_count); ?> отзывов о преподавателе →</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($branch_avg || $branch_cnt) : ?>
<div class="teacher-single__sp-item">
<strong class="teacher-single__sp-num"><?php echo esc_html($branch_avg); ?></strong>
<span class="teacher-single__sp-text">рейтинг школы в Яндекс Картах</span>
<?php if ($branch_cnt) : ?>
<span class="teacher-single__sp-link"><?php echo esc_html($branch_cnt); ?> отзывов на внешних площадках</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php endif; ?>
<?php
// ─────────────────── СЕГМЕНТЫ «ДЛЯ КОГО» ───────────────────
?>
@ -672,6 +702,7 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<p class="bento-p">Формат мини-группы закрывает три разные задачи выберите свою.</p>
</div>
<div class="teacher-single__segments-grid">
<?php $seg_num = 0; ?>
<?php foreach ($segments as $i => $seg) :
if ($i >= 3) {
break;
@ -681,9 +712,10 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
if (! $s_title) {
continue;
}
$seg_num++;
?>
<article class="teacher-single__segment-card">
<span class="teacher-single__segment-icon" aria-hidden="true"><?php echo esc_html(str_pad((string) ( $i + 1 ), 2, '0', STR_PAD_LEFT)); ?></span>
<span class="teacher-single__segment-icon" aria-hidden="true"><?php echo esc_html(str_pad((string) $seg_num, 2, '0', STR_PAD_LEFT)); ?></span>
<h3 class="teacher-single__segment-title"><?php echo esc_html($s_title); ?></h3>
<p class="teacher-single__segment-text"><?php echo esc_html($s_text); ?></p>
</article>
@ -743,15 +775,17 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<div class="teacher-single__story-steps">
<h2 class="bento-h2">Как проходит первое занятие</h2>
<ol class="teacher-single__timeline">
<?php $step_num = 0; ?>
<?php foreach ($steps as $i => $step) :
$st_title = $step['step_title'] ?? '';
$st_text = $step['step_text'] ?? '';
if (! $st_title) {
continue;
}
$step_num++;
?>
<li class="teacher-single__timeline-item">
<span class="teacher-single__timeline-num" aria-hidden="true"><?php echo esc_html($i + 1); ?></span>
<span class="teacher-single__timeline-num" aria-hidden="true"><?php echo esc_html($step_num); ?></span>
<div class="teacher-single__timeline-content">
<h3><?php echo esc_html($st_title); ?></h3>
<?php if ($st_text) : ?><p><?php echo esc_html($st_text); ?></p><?php endif; ?>
@ -791,14 +825,24 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<div class="container">
<h2 id="teacher-cases-title" class="bento-h2">Результаты учеников</h2>
<div class="teacher-single__cases-tabs" role="tablist" aria-label="Класс">
<?php foreach ($case_tabs as $ci => $tab) : ?>
<button type="button" role="tab" id="case-tab-<?php echo esc_attr($tab); ?>" aria-controls="case-panel-<?php echo esc_attr($tab); ?>" aria-selected="<?php echo 0 === $ci ? 'true' : 'false'; ?>" class="teacher-single__case-tab<?php echo 0 === $ci ? ' is-active' : ''; ?>" data-case-tab="<?php echo esc_attr($tab); ?>">
<?php foreach ($case_tabs as $ci => $tab) :
$tab_id = sanitize_title($tab) . '-' . $ci;
if ('' === trim($tab_id, '-')) {
$tab_id = 'tab-' . $ci;
}
?>
<button type="button" role="tab" id="case-tab-<?php echo esc_attr($tab_id); ?>" aria-controls="case-panel-<?php echo esc_attr($tab_id); ?>" aria-selected="<?php echo 0 === $ci ? 'true' : 'false'; ?>" class="teacher-single__case-tab<?php echo 0 === $ci ? ' is-active' : ''; ?>" data-case-tab="<?php echo esc_attr($tab); ?>">
<?php echo esc_html($tab); ?> класс
</button>
<?php endforeach; ?>
</div>
<?php foreach ($case_tabs as $ci => $tab) : ?>
<div role="tabpanel" id="case-panel-<?php echo esc_attr($tab); ?>" aria-labelledby="case-tab-<?php echo esc_attr($tab); ?>" class="teacher-single__case-panel<?php echo 0 === $ci ? ' is-active' : ''; ?>" data-case-panel="<?php echo esc_attr($tab); ?>">
<?php foreach ($case_tabs as $ci => $tab) :
$tab_id = sanitize_title($tab) . '-' . $ci;
if ('' === trim($tab_id, '-')) {
$tab_id = 'tab-' . $ci;
}
?>
<div role="tabpanel" id="case-panel-<?php echo esc_attr($tab_id); ?>" aria-labelledby="case-tab-<?php echo esc_attr($tab_id); ?>" class="teacher-single__case-panel<?php echo 0 === $ci ? ' is-active' : ''; ?>" data-case-panel="<?php echo esc_attr($tab); ?>">
<div class="teacher-single__cases-grid">
<?php foreach ($cases as $case) :
if (trim((string) ( $case['case_tab'] ?? '' )) !== $tab || empty($case['case_text'])) {
@ -818,31 +862,15 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<?php endif; ?>
<?php
// ─────────────────── ОБРАЗОВАНИЕ / ПРЕДМЕТЫ / СЕРТИФИКАТЫ ───────────────────
if ($teacher_subjects || $education || $add_education || carbon_get_post_meta($tid, 'certificates')) :
$certificates = (array) carbon_get_post_meta($tid, 'certificates');
// ─────────────────── ОБРАЗОВАНИЕ / СЕРТИФИКАТЫ ───────────────────
// Направления здесь не дублируем — они уже показаны выше в секции «Программы и направления».
$certificates = (array) carbon_get_post_meta($tid, 'certificates');
if ($education || $add_education || ! empty($certificates)) :
?>
<section class="teacher-single__edu section-bg reveal" data-bg="teacher-edu" aria-labelledby="teacher-edu-title">
<div class="container">
<h2 id="teacher-edu-title" class="bento-h2">Образование и опыт</h2>
<div class="teacher-single__edu-grid">
<?php if ($teacher_subjects) : ?>
<div class="teacher-single__edu-col">
<h3 class="teacher-single__edu-h">Направления</h3>
<ul class="teacher-single__subject-list">
<?php foreach ($teacher_subjects as $ts) :
$s_title = $ts['teacher_subjects_title'] ?? '';
if (! $s_title) {
continue;
}
?>
<li>
<strong><?php echo esc_html($s_title); ?></strong>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($education || $add_education) : ?>
<div class="teacher-single__edu-col">
<?php if ($education) : ?>
@ -859,18 +887,25 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<?php if ($certificates) : ?>
<div class="teacher-single__cert-grid">
<?php foreach ($certificates as $cert) :
$c_id = $cert['certificates_foto'] ?? '';
if (! $c_id) {
continue;
}
$c_url = wp_get_attachment_url($c_id);
$c_alt = $c_id ? get_post_meta($c_id, '_wp_attachment_image_alt', true) : '';
if (! $c_url) {
$c_id = $cert['certificates_foto'] ?? '';
$c_alt = $c_id ? get_post_meta((int) $c_id, '_wp_attachment_image_alt', true) : '';
// image() = проверка наличия + вывод, без отдельного url-запроса.
$c_img = $c_id ? wp_get_attachment_image(
(int) $c_id,
'medium',
false,
array(
'alt' => $c_alt ?: ('Диплом или сертификат — ' . $full_name),
'loading' => 'lazy',
'decoding' => 'async',
)
) : '';
if (! $c_img) {
continue;
}
?>
<figure class="teacher-single__cert">
<img src="<?php echo esc_url($c_url); ?>" alt="<?php echo esc_attr($c_alt ?: 'Диплом или сертификат — ' . $full_name); ?>" loading="lazy" decoding="async">
<?php echo $c_img; ?>
</figure>
<?php endforeach; ?>
</div>
@ -899,7 +934,7 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
<?php endif; ?>
<?php
// ───────────── ОТЗЫВЫ О ПРЕПОДАВАТЕЛЕ (bento-блок «как на главной», part reviews-link) ─────────────
// ─── ОТЗЫВЫ О ПРЕПОДАВАТЕЛЕ (part reviews-link, как на главной) ───
$show_social_reviews = carbon_get_post_meta($tid, 'teacher_show_social_reviews');
if ('no' !== (string) $show_social_reviews) {
get_template_part('template-parts/reviews-link', null, array( 'teacher' => $tid ));
@ -922,7 +957,7 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
</section>
<?php
// ─────────────────── ДРУГИЕ ПРЕПОДАВАТЕЛИ (общий блок сайта, как на главной) ───────────────────
// ─── ДРУГИЕ ПРЕПОДАВАТЕЛИ (общий блок сайта) ───
get_template_part('template-parts/teachers-cards');
?>
@ -994,10 +1029,10 @@ $schema_graph[] = array('@type' => 'BreadcrumbList', 'itemListElement' => $bread
</section>
<?php
// ─────────────────── КОНТАКТЫ / НАШ АДРЕС (шаблон главной) ───────────────────
// ─── КОНТАКТЫ / АДРЕС ───
get_template_part('template-parts/contacts');
// ─────────────────── БЛОК ЭКСПЕРТНОСТИ (E-E-A-T): автор — сам преподаватель, под картой ───────────────────
// ─── ЭКСПЕРТНОСТЬ (E-E-A-T): автор — преподаватель, под картой ───
$teacher_creds = trim(implode(', ', array_filter(array(
$direction ?: '',
($tenure ? 'педагогический стаж ' . $tenure . ' лет' : ''),