1555 lines
53 KiB
PHP
1555 lines
53 KiB
PHP
<?php
|
||
/**
|
||
* Schema.org JSON-LD Generator — единый @graph
|
||
*
|
||
* Генерирует микроразметку для страницы «Подготовка к школе».
|
||
* Только динамические данные из Carbon Fields. Никаких хардкод-значений.
|
||
*
|
||
* @package Dekart
|
||
* @since 2026-05-31
|
||
* @see https://schema.org/Course
|
||
*/
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
/**
|
||
* Отключаем Yoast SEO JSON-LD на странице Подготовки к школе,
|
||
* чтобы избежать дублирования WebPage + Organization.
|
||
* Аналогично page-camp.php.
|
||
*/
|
||
add_filter( 'wpseo_json_ld_output', '__return_false' );
|
||
|
||
/**
|
||
* Принудительно меняем og:type на 'website' для страницы Подготовки к школе,
|
||
* чтобы избежать несоответствия article/website в социальных сниппетах.
|
||
*/
|
||
add_filter( 'wpseo_opengraph_type', 'dekart_schema_og_type', 10, 1 );
|
||
function dekart_schema_og_type( string $type ): string {
|
||
if ( is_page_template( 'page-podgotovka-k-shkole.php' ) || is_page_template( 'page-camp.php' ) ) {
|
||
return 'website';
|
||
}
|
||
return $type;
|
||
}
|
||
|
||
/**
|
||
* Убираем article:* OG-теги для страницы Подготовки к школе,
|
||
* чтобы не было конфликта og:type=website с article:modified_time.
|
||
* Аналог page-camp.php:15-27.
|
||
*/
|
||
add_filter( 'wpseo_frontend_presenter_classes', function( array $presenters ): array {
|
||
if ( ! is_page_template( 'page-podgotovka-k-shkole.php' ) ) {
|
||
return $presenters;
|
||
}
|
||
$presenters = array_filter( $presenters, function( string $p ): bool {
|
||
return strpos( $p, 'Article_Modified_Time' ) === false
|
||
&& strpos( $p, 'Article_Published_Time' ) === false
|
||
&& strpos( $p, 'Article_Author' ) === false
|
||
&& strpos( $p, 'Article_Publisher' ) === false
|
||
&& strpos( $p, 'Label1' ) === false
|
||
&& strpos( $p, 'Data1' ) === false;
|
||
} );
|
||
return $presenters;
|
||
} );
|
||
|
||
/**
|
||
* Главная функция: сборка @graph и вывод в wp_head.
|
||
* Срабатывает только на странице с шаблоном page-podgotovka-k-shkole.php.
|
||
*/
|
||
add_action( 'wp_head', 'dekart_schema_output', 1 );
|
||
|
||
/**
|
||
* Возвращает URL текущей страницы для Schema.org @id.
|
||
* Динамически через get_permalink() — не зависит от хардкода ID.
|
||
*/
|
||
function dekart_schema_page_url(): string {
|
||
$id = get_the_ID();
|
||
return $id ? get_permalink( $id ) : home_url( '/podgotovka-k-shkole/' );
|
||
}
|
||
|
||
function dekart_schema_output(): void {
|
||
if ( ! is_page_template( 'page-podgotovka-k-shkole.php' ) ) {
|
||
return;
|
||
}
|
||
|
||
// Guard: без Carbon Fields — тихий выход (не может быть фатала)
|
||
if ( ! function_exists( 'carbon_get_post_meta' ) || ! function_exists( 'carbon_get_theme_option' ) ) {
|
||
return;
|
||
}
|
||
|
||
// Object Cache key: page ID + locale + schema version
|
||
$cache_key = 'dekart_schema_graph_' . get_the_ID() . '_' . determine_locale();
|
||
$cached = wp_cache_get( $cache_key, 'dekart_schema' );
|
||
|
||
if ( false !== $cached ) {
|
||
echo $cached;
|
||
return;
|
||
}
|
||
|
||
$graph = array();
|
||
|
||
// 1. WebPage
|
||
$webpage = dekart_schema_webpage();
|
||
if ( $webpage ) {
|
||
$graph[] = $webpage;
|
||
}
|
||
|
||
// 2. WebSite
|
||
$website = dekart_schema_website();
|
||
if ( $website ) {
|
||
$graph[] = $website;
|
||
}
|
||
|
||
// 3. BreadcrumbList
|
||
$breadcrumb = dekart_schema_breadcrumb();
|
||
if ( $breadcrumb ) {
|
||
$graph[] = $breadcrumb;
|
||
}
|
||
|
||
// 4. Parent Organization (юридическое лицо, без адреса)
|
||
$organization = dekart_schema_organization();
|
||
if ( $organization ) {
|
||
$graph[] = $organization;
|
||
}
|
||
|
||
// 5. LocalBusiness (филиал/центр, с адресом)
|
||
$local_business = dekart_schema_local_business();
|
||
if ( $local_business ) {
|
||
$graph[] = $local_business;
|
||
}
|
||
|
||
// 6. Course
|
||
$course = dekart_schema_course();
|
||
if ( $course ) {
|
||
$graph[] = $course;
|
||
}
|
||
|
||
// 7. Offer / AggregateOffer (conditional)
|
||
$offers = dekart_schema_offer();
|
||
if ( ! empty( $offers ) ) {
|
||
if ( isset( $offers[0] ) && is_array( $offers[0] ) ) {
|
||
// Вернулся массив Offer'ов — добавляем каждый в граф
|
||
foreach ( $offers as $offer ) {
|
||
$graph[] = $offer;
|
||
}
|
||
} else {
|
||
// Один Offer (для обратной совместимости)
|
||
$graph[] = $offers;
|
||
}
|
||
}
|
||
|
||
// 8. AggregateRating (conditional — есть ли отзывы с оценками)
|
||
$rating = dekart_schema_rating();
|
||
if ( $rating ) {
|
||
$graph[] = $rating;
|
||
}
|
||
|
||
// 9. Service — сущность услуги (только для страниц услуг, не для /otzyvy/)
|
||
$service = dekart_schema_service();
|
||
if ( $service ) {
|
||
$graph[] = $service;
|
||
}
|
||
|
||
// 11. FAQPage (conditional — есть ли вопросы)
|
||
$faq = dekart_schema_faq();
|
||
if ( $faq ) {
|
||
$graph[] = $faq;
|
||
}
|
||
|
||
// 11b. Event — бесплатное пробное занятие
|
||
$event = dekart_schema_trial_event();
|
||
if ( $event ) {
|
||
$graph[] = $event;
|
||
}
|
||
|
||
// 12. ItemList — обёртка для отзывов (conditional)
|
||
$reviews = dekart_schema_reviews();
|
||
if ( ! empty( $reviews ) && is_array( $reviews ) ) {
|
||
$graph[] = array(
|
||
'@type' => 'ItemList',
|
||
'@id' => dekart_schema_page_url() . '#reviews',
|
||
'name' => 'Отзывы о подготовке к школе',
|
||
'description' => 'Отзывы родителей о курсе подготовки к школе в Детском центре «Декарт»',
|
||
'itemListElement' => array_map( function( $review, $index ) {
|
||
return array(
|
||
'@type' => 'ListItem',
|
||
'position' => $index + 1,
|
||
'item' => $review,
|
||
);
|
||
}, $reviews, array_keys( $reviews ) ),
|
||
'numberOfItems' => count( $reviews ),
|
||
);
|
||
}
|
||
|
||
// 13. Person[] — преподаватели (conditional)
|
||
$teachers = dekart_schema_teachers();
|
||
if ( ! empty( $teachers ) && is_array( $teachers ) ) {
|
||
foreach ( $teachers as $person ) {
|
||
$graph[] = $person;
|
||
}
|
||
}
|
||
|
||
if ( empty( $graph ) ) {
|
||
return;
|
||
}
|
||
|
||
$json = wp_json_encode(
|
||
array(
|
||
'@context' => 'https://schema.org',
|
||
'@graph' => $graph,
|
||
),
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||
);
|
||
|
||
if ( false === $json ) {
|
||
return; // тихий выход вместо WSOD
|
||
}
|
||
|
||
$output = '<script type="application/ld+json">' . "\n" . $json . "\n" . '</script>' . "\n";
|
||
|
||
// Cache for 1 hour (3600 seconds) in 'dekart_schema' group
|
||
wp_cache_set( $cache_key, $output, 'dekart_schema', 3600 );
|
||
|
||
echo $output;
|
||
}
|
||
|
||
/* =====================================================================
|
||
Сущности @graph
|
||
===================================================================== */
|
||
|
||
/**
|
||
* WebPage — текущая страница.
|
||
* @see https://schema.org/WebPage
|
||
*/
|
||
function dekart_schema_webpage(): ?array {
|
||
$post_id = get_the_ID();
|
||
$url = get_permalink( $post_id ) ?: dekart_schema_page_url();
|
||
|
||
// _yoast_wpseo_title — Yoast SEO заголовок
|
||
$yoast_title = get_post_meta( $post_id, '_yoast_wpseo_title', true );
|
||
$name = $yoast_title ?: wp_get_document_title();
|
||
|
||
// _yoast_wpseo_metadesc — Yoast SEO описание
|
||
$description = get_post_meta( $post_id, '_yoast_wpseo_metadesc', true );
|
||
$description = $description ?: get_post_meta( $post_id, 'rank_math_description', true );
|
||
$description = $description ?: carbon_get_post_meta( $post_id, 'banner_subtitle' );
|
||
$description = $description ?: 'Подготовка к школе для детей 5–7 лет в Щёлково. Чтение, письмо, математика, логика.';
|
||
|
||
// has_post_thumbnail — изображение страницы (OG-совместимость)
|
||
$image_url = '';
|
||
if ( has_post_thumbnail( get_the_ID() ) ) {
|
||
$image_url = get_the_post_thumbnail_url( 2, 'full' );
|
||
}
|
||
|
||
// pksh_level — уровень образования (about)
|
||
$about_text = carbon_get_post_meta( get_the_ID(), 'pksh_level' ) ?: 'Подготовка к школе';
|
||
|
||
// branch_city — город для about и keywords
|
||
$city = dekart_branch_option( 'branch_city', 'Щёлково' );
|
||
|
||
$webpage = array(
|
||
'@type' => 'WebPage',
|
||
'@id' => $url . '#webpage',
|
||
'url' => $url,
|
||
'name' => wp_strip_all_tags( $name ),
|
||
'description' => wp_strip_all_tags( $description ),
|
||
'inLanguage' => str_replace( '_', '-', determine_locale() ?: 'ru' ),
|
||
'isPartOf' => array(
|
||
'@id' => home_url( '/' ) . '#website',
|
||
),
|
||
'breadcrumb' => array(
|
||
'@id' => $url . '#breadcrumb',
|
||
),
|
||
// mainEntity — основной курс на странице
|
||
'mainEntity' => array(
|
||
'@id' => $url . '#course',
|
||
),
|
||
// speakable — для голосовых помощников и AI-поиска
|
||
'speakable' => array(
|
||
'@type' => 'SpeakableSpecification',
|
||
'cssSelector' => array(
|
||
'h1.ds-hero__h1',
|
||
'.ds-hero__subtitle',
|
||
),
|
||
),
|
||
// about — тематика страницы (образовательная услуга)
|
||
'about' => array(
|
||
'@type' => 'Thing',
|
||
'name' => wp_strip_all_tags( sprintf( '%s в %s', $about_text, $city ) ),
|
||
),
|
||
// keywords — метки для поиска
|
||
'keywords' => wp_strip_all_tags(
|
||
'подготовка к школе, дошкольное образование, обучение чтению, обучение письму, '
|
||
. 'математика для дошкольников, развитие логики, курсы для детей 5-7 лет, '
|
||
. $city
|
||
),
|
||
);
|
||
|
||
if ( $image_url ) {
|
||
$webpage['image'] = esc_url( $image_url );
|
||
}
|
||
|
||
// datePublished / dateModified — даты публикации страницы
|
||
$post_obj = get_post( 2 );
|
||
if ( $post_obj ) {
|
||
$webpage['datePublished'] = get_the_date( 'c', $post_obj );
|
||
$webpage['dateModified'] = get_the_modified_date( 'c', $post_obj );
|
||
}
|
||
|
||
// hasPart — ссылка на FAQPage этой страницы
|
||
$faq_items = carbon_get_post_meta( get_the_ID(), 'pksh_faq_items' );
|
||
if ( ! empty( $faq_items ) && is_array( $faq_items ) ) {
|
||
$webpage['hasPart'] = array(
|
||
'@id' => $url . '#faq',
|
||
);
|
||
}
|
||
|
||
return $webpage;
|
||
}
|
||
|
||
/**
|
||
* WebSite — главный сайт.
|
||
* name из branch_name (единый источник с лагерем), а не из site_name.
|
||
*/
|
||
function dekart_schema_website(): ?array {
|
||
$site_name = dekart_branch_option( 'branch_name', 'Детский центр «Декарт»' );
|
||
$home_url = home_url( '/' );
|
||
|
||
return array(
|
||
'@type' => 'WebSite',
|
||
'@id' => $home_url . '#website',
|
||
'url' => $home_url,
|
||
'name' => wp_strip_all_tags( $site_name ),
|
||
'potentialAction' => array(
|
||
'@type' => 'SearchAction',
|
||
'target' => array(
|
||
'@type' => 'EntryPoint',
|
||
'urlTemplate' => $home_url . '?s={search_term_string}',
|
||
),
|
||
'query-input' => 'required name=search_term_string',
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* BreadcrumbList — хлебные крошки для страницы.
|
||
*/
|
||
function dekart_schema_breadcrumb(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$home_url = home_url( '/' );
|
||
|
||
return array(
|
||
'@type' => 'BreadcrumbList',
|
||
'@id' => $page_url . '#breadcrumb',
|
||
'itemListElement' => array(
|
||
array(
|
||
'@type' => 'ListItem',
|
||
'position' => 1,
|
||
'name' => 'Главная',
|
||
'item' => $home_url,
|
||
),
|
||
array(
|
||
'@type' => 'ListItem',
|
||
'position' => 2,
|
||
'name' => 'Подготовка к школе',
|
||
'item' => $page_url,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Helper: получить значение из Carbon Fields (schema_*) с fallback на Settings API (branch_*).
|
||
* Работает с любыми типами (включая массивы complex/media_gallery).
|
||
* Приоритет: carbon_get_theme_option('schema_*') → get_option('branch_*') → $default.
|
||
*/
|
||
function dekart_schema_option( string $schema_key, string $branch_key, $default = '' ) {
|
||
$val = carbon_get_theme_option( $schema_key );
|
||
if ( ! empty( $val ) ) {
|
||
return $val;
|
||
}
|
||
$val = get_option( $branch_key );
|
||
if ( ! empty( $val ) ) {
|
||
return $val;
|
||
}
|
||
return $default;
|
||
}
|
||
|
||
/**
|
||
* Helper: получить значение из Настроек филиала (branch_*).
|
||
* Единый источник: get_option('branch_*').
|
||
* Схема-поля (schema_*) читать через dekart_schema_option() — CF → Settings API.
|
||
*/
|
||
function dekart_branch_option( string $key, string $default = '' ): string {
|
||
$val = get_option( $key );
|
||
if ( ! empty( $val ) && is_string( $val ) ) {
|
||
return trim( $val );
|
||
}
|
||
return $default;
|
||
}
|
||
|
||
/**
|
||
* Parent Organization — юридическое лицо (без адреса филиала).
|
||
* @id совпадает с лагерем — это одна организация.
|
||
*/
|
||
function dekart_schema_organization(): ?array {
|
||
$home_url = home_url( '/' );
|
||
$site_name = dekart_branch_option( 'branch_name', 'Детский центр «Декарт»' );
|
||
|
||
// Логотип: единая цепочка с page-camp.php
|
||
$logo_url = dekart_resolve_logo();
|
||
|
||
$org = array(
|
||
'@type' => 'Organization',
|
||
'@id' => $home_url . '#organization',
|
||
'name' => wp_strip_all_tags( $site_name ),
|
||
'url' => $home_url,
|
||
'logo' => esc_url( $logo_url ),
|
||
'image' => esc_url( $logo_url ),
|
||
);
|
||
|
||
// legalName — юридическое наименование (CF schema_legal_name → Settings API branch_legal_name)
|
||
$legal_name = dekart_schema_option( 'schema_legal_name', 'branch_legal_name' );
|
||
if ( ! empty( $legal_name ) && is_string( $legal_name ) ) {
|
||
$org['legalName'] = wp_strip_all_tags( trim( $legal_name ) );
|
||
}
|
||
|
||
// contactPoint — контактный телефон для связи
|
||
$cp_phone = dekart_branch_option( 'branch_phone', '' );
|
||
if ( $cp_phone ) {
|
||
$org['contactPoint'] = array(
|
||
'@type' => 'ContactPoint',
|
||
'telephone' => wp_strip_all_tags( $cp_phone ),
|
||
'contactType' => 'customer service',
|
||
'areaServed' => 'RU',
|
||
'availableLanguage' => 'Russian',
|
||
);
|
||
}
|
||
|
||
return $org;
|
||
}
|
||
|
||
/**
|
||
* Helper: цепочка разрешения логотипа (аналог page-camp.php:92-103).
|
||
* branch_logo (ACF) → banner_img (CF) → custom_logo (WP) → post_thumbnail → fallback SVG.
|
||
*/
|
||
function dekart_resolve_logo(): string {
|
||
// 1. branch_logo — ACF опция филиала
|
||
$branch_logo = get_option( 'branch_logo' );
|
||
if ( ! empty( $branch_logo ) ) {
|
||
if ( is_array( $branch_logo ) && isset( $branch_logo['url'] ) ) {
|
||
return $branch_logo['url'];
|
||
}
|
||
$url = wp_get_attachment_url( $branch_logo );
|
||
if ( $url ) {
|
||
return $url;
|
||
}
|
||
}
|
||
|
||
// 2. banner_img — CF поле страницы
|
||
$banner_img = carbon_get_post_meta( get_the_ID(), 'banner_img' );
|
||
if ( ! empty( $banner_img ) ) {
|
||
if ( is_array( $banner_img ) && isset( $banner_img['url'] ) ) {
|
||
return $banner_img['url'];
|
||
}
|
||
$url = wp_get_attachment_url( $banner_img );
|
||
if ( $url ) {
|
||
return $url;
|
||
}
|
||
}
|
||
|
||
// 3. custom_logo — WP Customizer
|
||
$logo_id = get_theme_mod( 'custom_logo' );
|
||
if ( $logo_id ) {
|
||
$url = wp_get_attachment_url( $logo_id );
|
||
if ( $url ) {
|
||
return $url;
|
||
}
|
||
}
|
||
|
||
// 4. post_thumbnail страницы
|
||
if ( has_post_thumbnail( get_the_ID() ) ) {
|
||
$url = get_the_post_thumbnail_url( 2, 'full' );
|
||
if ( $url ) {
|
||
return $url;
|
||
}
|
||
}
|
||
|
||
// 5. fallback — SVG из темы
|
||
return get_template_directory_uri() . '/assets/images/logo-svg.svg';
|
||
}
|
||
|
||
/**
|
||
* LocalBusiness — филиал/центр с адресом, телефоном.
|
||
* Данные из Настроек филиала (branch_*), единый источник с лагерем.
|
||
* @id на уровне страницы, parentOrganization → #organization.
|
||
*
|
||
* @see https://schema.org/LocalBusiness
|
||
* @see https://schema.org/EducationalOrganization
|
||
*/
|
||
function dekart_schema_local_business(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$home_url = home_url( '/' );
|
||
|
||
// branch_name — название филиала (get_option)
|
||
$site_name = dekart_branch_option( 'branch_name', 'Детский центр «Декарт»' );
|
||
// branch_address — улица (приоритет: Settings API → CF theme option → хардкод)
|
||
$street = get_option( 'branch_address' ) ?: carbon_get_theme_option( 'site_address' ) ?: 'Комсомольская ул., 11';
|
||
// branch_city — город
|
||
$city = dekart_branch_option( 'branch_city', 'Щёлково' );
|
||
// branch_region — регион/область
|
||
$region = dekart_branch_option( 'branch_region', 'Московская область' );
|
||
// branch_phone — телефон
|
||
$phone = dekart_branch_option( 'branch_phone', '' );
|
||
// branch_email — email
|
||
$email = dekart_branch_option( 'branch_email', '' );
|
||
// branch_coords — координаты "lat, lng" из Настроек филиала (Settings API)
|
||
$coords = get_option( 'branch_coords' );
|
||
$lat = '';
|
||
$lng = '';
|
||
if ( ! empty( $coords ) && is_string( $coords ) && false !== strpos( $coords, ',' ) ) {
|
||
$parts = explode( ',', $coords );
|
||
if ( count( $parts ) >= 2 ) {
|
||
$lat = trim( $parts[0] );
|
||
$lng = trim( $parts[1] );
|
||
}
|
||
}
|
||
// branch_postal_code — почтовый индекс (CF schema_postal_code → Settings API)
|
||
$zip = dekart_schema_option( 'schema_postal_code', 'branch_postal_code' );
|
||
// branch_price_range — диапазон цен (Settings API)
|
||
$price_range = get_option( 'branch_price_range' );
|
||
// branch_founded — год основания (CF schema_founded → Settings API)
|
||
$founded = dekart_schema_option( 'schema_founded', 'branch_founded' );
|
||
// branch_map_url — ссылка на карту для hasMap (CF schema_map_url → Settings API)
|
||
$map_url = dekart_schema_option( 'schema_map_url', 'branch_map_url' );
|
||
// branch_google_place_id — Google Place ID
|
||
$google_place_id = get_option( 'branch_google_place_id' );
|
||
// branch_google_maps_url — Google Maps URL для hasMap
|
||
$google_maps_url = get_option( 'branch_google_maps_url' );
|
||
// branch_area_served — города обслуживания (через запятую)
|
||
$area_served_raw = get_option( 'branch_area_served', 'Щёлково, Фрязино, Монино, Ивантеевка, Королёв' );
|
||
// branch_hours — часы работы: CF complex (schema_hours) → textarea (branch_hours)
|
||
$hours_cf = carbon_get_theme_option( 'schema_hours' );
|
||
|
||
$lb = array(
|
||
'@type' => array( 'EducationalOrganization', 'LocalBusiness' ),
|
||
'@id' => $page_url . '#localbusiness',
|
||
'name' => wp_strip_all_tags( $site_name ),
|
||
'url' => $home_url,
|
||
'address' => array(
|
||
'@type' => 'PostalAddress',
|
||
'streetAddress' => wp_strip_all_tags( $street ),
|
||
'addressLocality' => wp_strip_all_tags( $city ),
|
||
'addressRegion' => wp_strip_all_tags( $region ),
|
||
'addressCountry' => 'RU',
|
||
),
|
||
'parentOrganization' => array(
|
||
'@id' => $home_url . '#organization',
|
||
),
|
||
);
|
||
|
||
// postalCode — почтовый индекс (условно)
|
||
if ( ! empty( $zip ) && is_string( $zip ) ) {
|
||
$lb['address']['postalCode'] = trim( $zip );
|
||
}
|
||
|
||
// telephone — телефон (условно)
|
||
if ( $phone ) {
|
||
$lb['telephone'] = wp_strip_all_tags( $phone );
|
||
}
|
||
|
||
// email — почта (условно)
|
||
if ( $email ) {
|
||
$lb['email'] = wp_strip_all_tags( $email );
|
||
}
|
||
|
||
// geo — GeoCoordinates (условно: обе координаты не пусты)
|
||
if ( ! empty( $lat ) && ! empty( $lng ) ) {
|
||
$lb['geo'] = array(
|
||
'@type' => 'GeoCoordinates',
|
||
'latitude' => floatval( $lat ),
|
||
'longitude' => floatval( $lng ),
|
||
);
|
||
}
|
||
|
||
// hasMap — ссылка на карту (условно)
|
||
if ( ! empty( $map_url ) && is_string( $map_url ) ) {
|
||
$lb['hasMap'] = esc_url( trim( $map_url ) );
|
||
}
|
||
|
||
// openingHoursSpecification — часы работы
|
||
// Формат 1 (CF complex, schema_hours): [['day_of_week' => 'Monday', 'opens' => '09:00', 'closes' => '20:00', 'day_off' => '']]
|
||
// Формат 2 (Settings API textarea, branch_hours): "Monday|09:00|20:00"
|
||
$hours_spec = array();
|
||
if ( ! empty( $hours_cf ) && is_array( $hours_cf ) ) {
|
||
foreach ( $hours_cf as $entry ) {
|
||
$day = isset( $entry['day_of_week'] ) ? trim( $entry['day_of_week'] ) : '';
|
||
if ( empty( $day ) ) {
|
||
continue;
|
||
}
|
||
$is_off = ! empty( $entry['day_off'] );
|
||
if ( $is_off ) {
|
||
$hours_spec[] = array(
|
||
'@type' => 'OpeningHoursSpecification',
|
||
'dayOfWeek' => $day,
|
||
'opens' => '00:00',
|
||
'closes' => '00:00',
|
||
);
|
||
} else {
|
||
$from = isset( $entry['opens'] ) ? trim( $entry['opens'] ) : '';
|
||
$to = isset( $entry['closes'] ) ? trim( $entry['closes'] ) : '';
|
||
if ( $from && $to ) {
|
||
$hours_spec[] = array(
|
||
'@type' => 'OpeningHoursSpecification',
|
||
'dayOfWeek' => $day,
|
||
'opens' => $from,
|
||
'closes' => $to,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
$hours_raw = get_option( 'branch_hours' );
|
||
if ( ! empty( $hours_raw ) && is_string( $hours_raw ) ) {
|
||
$lines = explode( "\n", $hours_raw );
|
||
foreach ( $lines as $line ) {
|
||
$line = trim( $line );
|
||
if ( empty( $line ) ) {
|
||
continue;
|
||
}
|
||
$parts = explode( '|', $line );
|
||
$day = isset( $parts[0] ) ? trim( $parts[0] ) : '';
|
||
if ( empty( $day ) ) {
|
||
continue;
|
||
}
|
||
if ( isset( $parts[2] ) && 'closed' === strtolower( trim( $parts[2] ) ) ) {
|
||
$hours_spec[] = array(
|
||
'@type' => 'OpeningHoursSpecification',
|
||
'dayOfWeek' => $day,
|
||
'opens' => '00:00',
|
||
'closes' => '00:00',
|
||
);
|
||
continue;
|
||
}
|
||
$from = isset( $parts[1] ) ? trim( $parts[1] ) : '';
|
||
$to = isset( $parts[2] ) ? trim( $parts[2] ) : '';
|
||
if ( $day && $from && $to ) {
|
||
$hours_spec[] = array(
|
||
'@type' => 'OpeningHoursSpecification',
|
||
'dayOfWeek' => $day,
|
||
'opens' => $from,
|
||
'closes' => $to,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if ( ! empty( $hours_spec ) ) {
|
||
$lb['openingHoursSpecification'] = $hours_spec;
|
||
}
|
||
|
||
// priceRange — строка диапазона цен (условно)
|
||
if ( ! empty( $price_range ) && is_string( $price_range ) ) {
|
||
$lb['priceRange'] = trim( $price_range );
|
||
}
|
||
|
||
// Google Place ID — identifier + @id для Google Business Profile
|
||
if ( ! empty( $google_place_id ) && is_string( $google_place_id ) ) {
|
||
$lb['identifier'] = array(
|
||
'@type' => 'PropertyValue',
|
||
'name' => 'Google Place ID',
|
||
'value' => wp_strip_all_tags( $google_place_id ),
|
||
);
|
||
$lb['@id'] = 'https://search.google.com/local/writereview?placeid=' . urlencode( $google_place_id );
|
||
}
|
||
|
||
// areaServed — массив городов (branch_area_served: "Щёлково, Фрязино, Монино...")
|
||
$area_cities = array_map( 'trim', explode( ',', $area_served_raw ) );
|
||
$lb['areaServed'] = array_map( function( $c ) {
|
||
return array( '@type' => 'City', 'name' => wp_strip_all_tags( $c ) );
|
||
}, array_filter( $area_cities ) );
|
||
|
||
// hasMap — Google Maps URL (приоритет Google, потом Yandex)
|
||
if ( ! empty( $google_maps_url ) && is_string( $google_maps_url ) ) {
|
||
$lb['hasMap'] = esc_url( trim( $google_maps_url ) );
|
||
} elseif ( ! empty( $map_url ) && is_string( $map_url ) ) {
|
||
$lb['hasMap'] = esc_url( trim( $map_url ) );
|
||
}
|
||
|
||
// foundingDate — год основания (условно)
|
||
if ( ! empty( $founded ) && is_string( $founded ) ) {
|
||
$lb['foundingDate'] = trim( $founded );
|
||
}
|
||
|
||
// sameAs — массив URL соцсетей
|
||
// Формат 1 (CF schema_social complex): [['platform' => 'VK', 'url' => 'https://...']]
|
||
// Формат 2 (Settings API branch_social): [['name' => 'VK', 'url' => 'https://...']]
|
||
$social_cf = carbon_get_theme_option( 'schema_social' );
|
||
$same_as = array();
|
||
if ( ! empty( $social_cf ) && is_array( $social_cf ) ) {
|
||
foreach ( $social_cf as $entry ) {
|
||
$url = isset( $entry['url'] ) ? trim( $entry['url'] ) : '';
|
||
if ( $url && filter_var( $url, FILTER_VALIDATE_URL ) ) {
|
||
$same_as[] = esc_url( $url );
|
||
}
|
||
}
|
||
} else {
|
||
$social_raw = get_option( 'branch_social' );
|
||
if ( ! empty( $social_raw ) && is_array( $social_raw ) ) {
|
||
foreach ( $social_raw as $entry ) {
|
||
$url = isset( $entry['url'] ) ? trim( $entry['url'] ) : '';
|
||
if ( $url && filter_var( $url, FILTER_VALIDATE_URL ) ) {
|
||
$same_as[] = esc_url( $url );
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if ( ! empty( $same_as ) ) {
|
||
$lb['sameAs'] = $same_as;
|
||
}
|
||
|
||
// Гарантируем WhatsApp и Telegram в sameAs, даже если не заданы в админке
|
||
$ensure_platforms = array(
|
||
'WhatsApp' => dekart_branch_option( 'branch_whatsapp', 'https://wa.me/79269311434' ),
|
||
'Telegram' => dekart_branch_option( 'branch_telegram', 'https://t.me/dekart_school' ),
|
||
);
|
||
foreach ( $ensure_platforms as $platform => $url ) {
|
||
if ( $url && filter_var( $url, FILTER_VALIDATE_URL ) ) {
|
||
$found = false;
|
||
foreach ( $same_as as $existing ) {
|
||
if ( false !== stripos( $existing, parse_url( $url, PHP_URL_HOST ) ) ) {
|
||
$found = true;
|
||
break;
|
||
}
|
||
}
|
||
if ( ! $found ) {
|
||
$same_as[] = esc_url( $url );
|
||
}
|
||
}
|
||
}
|
||
// Google Business Profile — добавляем в sameAs, если ссылка задана
|
||
$gbp_url = get_option( 'branch_google_business_url', '' );
|
||
if ( ! empty( $gbp_url ) && filter_var( $gbp_url, FILTER_VALIDATE_URL ) ) {
|
||
$found_gbp = false;
|
||
foreach ( $same_as as $existing ) {
|
||
if ( false !== stripos( $existing, parse_url( $gbp_url, PHP_URL_HOST ) ) ) {
|
||
$found_gbp = true;
|
||
break;
|
||
}
|
||
}
|
||
if ( ! $found_gbp ) {
|
||
$same_as[] = esc_url( $gbp_url );
|
||
}
|
||
}
|
||
|
||
// Яндекс Бизнес — добавляем в sameAs, если ссылка задана
|
||
$yb_url = get_option( 'branch_yandex_business_url', '' );
|
||
if ( ! empty( $yb_url ) && filter_var( $yb_url, FILTER_VALIDATE_URL ) ) {
|
||
$found_yb = false;
|
||
foreach ( $same_as as $existing ) {
|
||
if ( false !== stripos( $existing, parse_url( $yb_url, PHP_URL_HOST ) ) ) {
|
||
$found_yb = true;
|
||
break;
|
||
}
|
||
}
|
||
if ( ! $found_yb ) {
|
||
$same_as[] = esc_url( $yb_url );
|
||
}
|
||
}
|
||
|
||
// Если после добавления появились элементы, обновляем sameAs
|
||
if ( ! empty( $same_as ) && ! isset( $lb['sameAs'] ) ) {
|
||
$lb['sameAs'] = $same_as;
|
||
}
|
||
|
||
// image — массив URL фотографий филиала
|
||
// Формат 1 (CF schema_photos media_gallery): [123, 456]
|
||
// Формат 2 (Settings API branch_photos): "123, 456"
|
||
$photos_cf = carbon_get_theme_option( 'schema_photos' );
|
||
$images = array();
|
||
if ( ! empty( $photos_cf ) && is_array( $photos_cf ) ) {
|
||
foreach ( $photos_cf as $id ) {
|
||
if ( ! is_numeric( $id ) || $id <= 0 ) {
|
||
continue;
|
||
}
|
||
$url = wp_get_attachment_url( (int) $id );
|
||
if ( $url ) {
|
||
$images[] = esc_url( $url );
|
||
}
|
||
}
|
||
} else {
|
||
$photos_raw = get_option( 'branch_photos' );
|
||
if ( ! empty( $photos_raw ) && is_string( $photos_raw ) ) {
|
||
$ids = explode( ',', $photos_raw );
|
||
foreach ( $ids as $id ) {
|
||
$id = trim( $id );
|
||
if ( ! is_numeric( $id ) || $id <= 0 ) {
|
||
continue;
|
||
}
|
||
$url = wp_get_attachment_url( (int) $id );
|
||
if ( $url ) {
|
||
$images[] = esc_url( $url );
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if ( ! empty( $images ) ) {
|
||
$lb['image'] = $images;
|
||
}
|
||
|
||
// employee — преподаватели (Person), если есть
|
||
$teachers = carbon_get_post_meta( get_the_ID(), 'pksh_teachers' );
|
||
if ( ! empty( $teachers ) && is_array( $teachers ) ) {
|
||
$employee_ids = array();
|
||
foreach ( $teachers as $i => $t ) {
|
||
$t_id = isset( $t['teacher_id'] ) ? intval( $t['teacher_id'] ) : 0;
|
||
if ( $t_id > 0 ) {
|
||
$employee_ids[] = array(
|
||
'@id' => $page_url . '#teacher-' . $i,
|
||
);
|
||
}
|
||
}
|
||
if ( ! empty( $employee_ids ) ) {
|
||
$lb['employee'] = $employee_ids;
|
||
}
|
||
}
|
||
|
||
return $lb;
|
||
}
|
||
|
||
/**
|
||
* Course — основной курс «Подготовка к школе».
|
||
* Данные из Carbon Fields: banner_h1, banner_subtitle.
|
||
*
|
||
* @see https://schema.org/Course
|
||
* @see https://schema.org/CourseInstance
|
||
*/
|
||
function dekart_schema_course(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$home_url = home_url( '/' );
|
||
|
||
// banner_h1 — заголовок H1 (Course.name)
|
||
$name = carbon_get_post_meta( get_the_ID(), 'banner_h1' ) ?: 'Подготовка к школе 5–7 лет в Щёлково';
|
||
// pksh_description — полное описание курса для микроразметки (textarea)
|
||
// Если заполнено — приоритет над banner_subtitle
|
||
$pksh_description = carbon_get_post_meta( get_the_ID(), 'pksh_description' );
|
||
// banner_subtitle — подзаголовок (Course.description)
|
||
$description = ! empty( $pksh_description ) ? $pksh_description : ( carbon_get_post_meta( get_the_ID(), 'banner_subtitle' ) ?: 'Научим читать, считать и писать до школы. Бесплатное пробное занятие.' );
|
||
// Пробное занятие — есть ли в подзаголовке упоминание бесплатно
|
||
$has_trial = ! empty( $description ) && false !== mb_stripos( $description, 'бесплатно' );
|
||
// pksh_level — уровень образования
|
||
$level = carbon_get_post_meta( get_the_ID(), 'pksh_level' );
|
||
// pksh_age — возраст
|
||
$age = carbon_get_post_meta( get_the_ID(), 'pksh_age' );
|
||
// pksh_audience — целевая аудитория
|
||
$audience = carbon_get_post_meta( get_the_ID(), 'pksh_audience' );
|
||
// pksh_course_mode — формат обучения (очная/ONLINE/OFFLINE)
|
||
$course_mode = carbon_get_post_meta( get_the_ID(), 'pksh_course_mode' );
|
||
// pksh_skills — список навыков
|
||
$skills = carbon_get_post_meta( get_the_ID(), 'pksh_skills' );
|
||
// banner_img — изображение курса
|
||
$course_img = carbon_get_post_meta( get_the_ID(), 'banner_img' );
|
||
// pksh_programs — программы (для timeRequired + CourseInstance)
|
||
$programs = carbon_get_post_meta( get_the_ID(), 'pksh_programs' );
|
||
|
||
$course = array(
|
||
'@type' => 'Course',
|
||
'@id' => $page_url . '#course',
|
||
'name' => wp_strip_all_tags( $name ),
|
||
'description' => wp_strip_all_tags( $description ),
|
||
'inLanguage' => str_replace( '_', '-', determine_locale() ?: 'ru' ),
|
||
'provider' => array(
|
||
'@id' => $page_url . '#localbusiness',
|
||
),
|
||
'offers' => array_merge(
|
||
array( array( '@id' => $page_url . '#offer' ) ),
|
||
$has_trial ? array( array( '@id' => $page_url . '#offer-trial' ) ) : array()
|
||
),
|
||
'aggregateRating' => array(
|
||
'@id' => $page_url . '#rating',
|
||
),
|
||
);
|
||
|
||
// review — 1-2 отзыва прямо в Course (Google требует для звёзд в rich snippet)
|
||
$reviews_raw = carbon_get_post_meta( get_the_ID(), 'pksh_reviews_list' );
|
||
if ( ! empty( $reviews_raw ) && is_array( $reviews_raw ) ) {
|
||
$review_items = array();
|
||
foreach ( $reviews_raw as $rv ) {
|
||
$author = isset( $rv['review_author'] ) ? trim( $rv['review_author'] ) : '';
|
||
$body = isset( $rv['review_text'] ) ? trim( $rv['review_text'] ) : '';
|
||
$rating = isset( $rv['review_rating'] ) ? intval( $rv['review_rating'] ) : 0;
|
||
if ( empty( $author ) || empty( $body ) || $rating < 1 || $rating > 5 ) {
|
||
continue;
|
||
}
|
||
$item = array(
|
||
'@type' => 'Review',
|
||
'author' => array( '@type' => 'Person', 'name' => wp_strip_all_tags( $author ) ),
|
||
'reviewBody' => wp_strip_all_tags( $body ),
|
||
'reviewRating' => array(
|
||
'@type' => 'Rating',
|
||
'ratingValue' => (string) $rating,
|
||
'bestRating' => '5',
|
||
'worstRating' => '1',
|
||
),
|
||
'itemReviewed' => array( '@id' => $page_url . '#course' ),
|
||
);
|
||
$date = isset( $rv['review_date'] ) ? trim( $rv['review_date'] ) : '';
|
||
if ( $date ) {
|
||
$item['datePublished'] = sanitize_text_field( $date );
|
||
}
|
||
$review_items[] = $item;
|
||
if ( count( $review_items ) >= 2 ) {
|
||
break;
|
||
}
|
||
}
|
||
if ( ! empty( $review_items ) ) {
|
||
$course['review'] = $review_items;
|
||
}
|
||
}
|
||
|
||
// educationalLevel — уровень образования (условно)
|
||
if ( ! empty( $level ) && is_string( $level ) ) {
|
||
$course['educationalLevel'] = wp_strip_all_tags( trim( $level ) );
|
||
}
|
||
|
||
// typicalAgeRange — возраст детей (условно, из pksh_age)
|
||
if ( ! empty( $age ) && is_string( $age ) ) {
|
||
$course['typicalAgeRange'] = wp_strip_all_tags( trim( $age ) );
|
||
}
|
||
|
||
// audience — EducationalAudience (условно)
|
||
if ( ! empty( $audience ) && is_string( $audience ) ) {
|
||
$course['audience'] = array(
|
||
'@type' => 'EducationalAudience',
|
||
'name' => wp_strip_all_tags( trim( $audience ) ),
|
||
);
|
||
}
|
||
|
||
// teaches — массив навыков (условно, из pksh_skills complex)
|
||
if ( ! empty( $skills ) && is_array( $skills ) ) {
|
||
$teaches = array();
|
||
foreach ( $skills as $skill ) {
|
||
$name = isset( $skill['skill'] ) ? trim( $skill['skill'] ) : '';
|
||
if ( $name ) {
|
||
$teaches[] = wp_strip_all_tags( $name );
|
||
}
|
||
}
|
||
if ( ! empty( $teaches ) ) {
|
||
$course['teaches'] = $teaches;
|
||
}
|
||
}
|
||
|
||
// timeRequired — длительность курса из первой программы, где заполнено
|
||
if ( ! empty( $programs ) && is_array( $programs ) ) {
|
||
foreach ( $programs as $prog ) {
|
||
if ( ! empty( $prog['program_time_required'] ) ) {
|
||
$course['timeRequired'] = wp_strip_all_tags( trim( $prog['program_time_required'] ) );
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// image — изображение курса (условно, из banner_img)
|
||
if ( ! empty( $course_img ) ) {
|
||
if ( is_array( $course_img ) && isset( $course_img['url'] ) ) {
|
||
$course['image'] = esc_url( $course_img['url'] );
|
||
} else {
|
||
$url = wp_get_attachment_url( $course_img );
|
||
if ( $url ) {
|
||
$course['image'] = esc_url( $url );
|
||
}
|
||
}
|
||
} elseif ( has_post_thumbnail( get_the_ID() ) ) {
|
||
// fallback: post_thumbnail страницы
|
||
$url = get_the_post_thumbnail_url( 2, 'full' );
|
||
if ( $url ) {
|
||
$course['image'] = esc_url( $url );
|
||
}
|
||
}
|
||
|
||
// hasCourseInstance — по одной CourseInstance на каждую программу с данными
|
||
$instances = array();
|
||
if ( ! empty( $programs ) && is_array( $programs ) ) {
|
||
foreach ( $programs as $prog ) {
|
||
$prog_workload = isset( $prog['program_workload'] ) ? trim( $prog['program_workload'] ) : '';
|
||
$prog_intensity = isset( $prog['program_intensity'] ) ? trim( $prog['program_intensity'] ) : '';
|
||
$prog_start = isset( $prog['program_start_date'] ) ? trim( $prog['program_start_date'] ) : '';
|
||
$prog_end = isset( $prog['program_end_date'] ) ? trim( $prog['program_end_date'] ) : '';
|
||
|
||
if ( empty( $prog_workload ) && empty( $prog_intensity ) && empty( $prog_start ) && empty( $prog_end ) ) {
|
||
continue; // нет данных для CourseInstance
|
||
}
|
||
|
||
$instance = array(
|
||
'@type' => 'CourseInstance',
|
||
'courseMode' => 'Onsite',
|
||
'location' => array(
|
||
'@id' => $page_url . '#localbusiness',
|
||
),
|
||
);
|
||
|
||
// courseMode — формат обучения (глобальный, единый для всех программ)
|
||
// Допустимые Schema.org: Onsite, Online, Blended
|
||
if ( ! empty( $course_mode ) && is_string( $course_mode ) ) {
|
||
$cm = strtolower( trim( $course_mode ) );
|
||
// Нормализация legacy-значений
|
||
$mode_map = array(
|
||
'offline' => 'Onsite',
|
||
'online' => 'Online',
|
||
'очная' => 'Onsite',
|
||
'очно' => 'Onsite',
|
||
);
|
||
$cm_normalized = isset( $mode_map[ $cm ] ) ? $mode_map[ $cm ] : ucfirst( $cm );
|
||
$valid_modes = array( 'Onsite', 'Online', 'Blended' );
|
||
if ( in_array( $cm_normalized, $valid_modes, true ) ) {
|
||
$instance['courseMode'] = $cm_normalized;
|
||
}
|
||
}
|
||
|
||
// courseSchedule — расписание (Schedule)
|
||
if ( $prog_workload || $prog_intensity || $prog_start || $prog_end ) {
|
||
$schedule = array( '@type' => 'Schedule' );
|
||
// repeatFrequency — ISO 8601 из program_repeat_frequency (новое поле)
|
||
$repeat_freq = isset( $prog['program_repeat_frequency'] ) ? trim( $prog['program_repeat_frequency'] ) : '';
|
||
if ( $repeat_freq ) {
|
||
$schedule['repeatFrequency'] = wp_strip_all_tags( $repeat_freq );
|
||
} elseif ( $prog_intensity ) {
|
||
// fallback: program_intensity — только если нет ISO-поля
|
||
$schedule['repeatFrequency'] = wp_strip_all_tags( $prog_intensity );
|
||
}
|
||
if ( $prog_start ) {
|
||
$schedule['startDate'] = $prog_start;
|
||
}
|
||
if ( $prog_end ) {
|
||
$schedule['endDate'] = $prog_end;
|
||
}
|
||
$instance['courseSchedule'] = $schedule;
|
||
}
|
||
|
||
// courseWorkload — учебная нагрузка
|
||
if ( $prog_workload ) {
|
||
$instance['courseWorkload'] = wp_strip_all_tags( $prog_workload );
|
||
}
|
||
|
||
$instances[] = $instance;
|
||
}
|
||
}
|
||
|
||
// fallback: если нет программ с данными, но есть courseMode — одна общая CourseInstance
|
||
if ( empty( $instances ) && ! empty( $course_mode ) && is_string( $course_mode ) ) {
|
||
$cm = strtolower( trim( $course_mode ) );
|
||
$mode_map = array(
|
||
'offline' => 'Onsite',
|
||
'online' => 'Online',
|
||
'очная' => 'Onsite',
|
||
'очно' => 'Onsite',
|
||
);
|
||
$cm_normalized = isset( $mode_map[ $cm ] ) ? $mode_map[ $cm ] : ucfirst( $cm );
|
||
$valid_modes = array( 'Onsite', 'Online', 'Blended' );
|
||
$instances[] = array(
|
||
'@type' => 'CourseInstance',
|
||
'courseMode' => ( in_array( $cm_normalized, $valid_modes, true ) ) ? $cm_normalized : 'Onsite',
|
||
'location' => array(
|
||
'@id' => $page_url . '#localbusiness',
|
||
),
|
||
);
|
||
}
|
||
|
||
if ( ! empty( $instances ) ) {
|
||
$course['hasCourseInstance'] = $instances;
|
||
}
|
||
|
||
return $course;
|
||
}
|
||
|
||
/**
|
||
* AggregateRating — рейтинг из отзывов Carbon Fields.
|
||
* Вычисляется динамически из pksh_reviews_list.
|
||
* Если отзывов < 1 — блок не выводится.
|
||
*/
|
||
function dekart_schema_rating(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$reviews = carbon_get_post_meta( get_the_ID(), 'pksh_reviews_list' );
|
||
|
||
if ( empty( $reviews ) || ! is_array( $reviews ) ) {
|
||
return null;
|
||
}
|
||
|
||
$ratings = array();
|
||
foreach ( $reviews as $review ) {
|
||
$rating = isset( $review['review_rating'] ) ? intval( $review['review_rating'] ) : 0;
|
||
if ( $rating >= 1 && $rating <= 5 ) {
|
||
$ratings[] = $rating;
|
||
}
|
||
}
|
||
|
||
if ( count( $ratings ) < 1 ) {
|
||
return null;
|
||
}
|
||
|
||
$average = round( array_sum( $ratings ) / count( $ratings ), 1 );
|
||
|
||
return array(
|
||
'@type' => 'AggregateRating',
|
||
'@id' => $page_url . '#rating',
|
||
'ratingValue' => (string) $average,
|
||
'bestRating' => '5',
|
||
'worstRating' => '1',
|
||
'ratingCount' => (string) count( $ratings ),
|
||
'itemReviewed' => array(
|
||
'@id' => $page_url . '#course',
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Service — сущность услуги «Подготовка к школе».
|
||
* provider → #organization (единая школа).
|
||
* Google видит Service как частный случай услуги организации.
|
||
*
|
||
* @see https://schema.org/Service
|
||
*/
|
||
function dekart_schema_service(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$home_url = home_url( '/' );
|
||
|
||
$name = carbon_get_post_meta( get_the_ID(), 'banner_h1' ) ?: 'Подготовка к школе';
|
||
$description = carbon_get_post_meta( get_the_ID(), 'banner_subtitle' ) ?: '';
|
||
|
||
$service = array(
|
||
'@type' => 'Service',
|
||
'@id' => $page_url . '#service',
|
||
'name' => wp_strip_all_tags( $name ),
|
||
'provider' => array(
|
||
'@id' => $home_url . '#organization',
|
||
),
|
||
'areaServed' => array(
|
||
'@type' => 'City',
|
||
'name' => dekart_branch_option( 'branch_city', 'Щёлково' ),
|
||
),
|
||
);
|
||
|
||
if ( $description ) {
|
||
$service['description'] = wp_strip_all_tags( $description );
|
||
}
|
||
|
||
return $service;
|
||
}
|
||
|
||
/**
|
||
* FAQPage — вопросы и ответы из pksh_faq_items.
|
||
* Если вопросов нет — блок не выводится.
|
||
*/
|
||
function dekart_schema_faq(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$faq_items = carbon_get_post_meta( get_the_ID(), 'pksh_faq_items' );
|
||
|
||
if ( empty( $faq_items ) || ! is_array( $faq_items ) ) {
|
||
return null;
|
||
}
|
||
|
||
$main_entity = array();
|
||
foreach ( $faq_items as $item ) {
|
||
$question = isset( $item['faq_question'] ) ? trim( $item['faq_question'] ) : '';
|
||
$answer = isset( $item['faq_answer'] ) ? trim( $item['faq_answer'] ) : '';
|
||
|
||
if ( empty( $question ) || empty( $answer ) ) {
|
||
continue;
|
||
}
|
||
|
||
$main_entity[] = array(
|
||
'@type' => 'Question',
|
||
'name' => wp_strip_all_tags( $question ),
|
||
'acceptedAnswer' => array(
|
||
'@type' => 'Answer',
|
||
'text' => wp_strip_all_tags( $answer ),
|
||
),
|
||
);
|
||
}
|
||
|
||
if ( empty( $main_entity ) ) {
|
||
return null;
|
||
}
|
||
|
||
return array(
|
||
'@type' => 'FAQPage',
|
||
'@id' => $page_url . '#faq',
|
||
'mainEntity' => $main_entity,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Review[] — поштучные отзывы из pksh_reviews_list.
|
||
* Каждый отзыв — отдельная сущность Review с автор-персоной и оценкой.
|
||
* itemReviewed → Course. Фильтр некорректных оценок (>5 или <1).
|
||
*
|
||
* @return array[] Массив Review-сущностей для @graph.
|
||
* @see https://schema.org/Review
|
||
*/
|
||
function dekart_schema_reviews(): array {
|
||
$page_url = dekart_schema_page_url();
|
||
// pksh_reviews_list — complex-поле отзывов
|
||
$reviews = carbon_get_post_meta( get_the_ID(), 'pksh_reviews_list' );
|
||
|
||
if ( empty( $reviews ) || ! is_array( $reviews ) ) {
|
||
return array();
|
||
}
|
||
|
||
$result = array();
|
||
$review_seq = 0;
|
||
foreach ( $reviews as $i => $review ) {
|
||
// review_author — имя автора (Person.name)
|
||
$author_name = isset( $review['review_author'] ) ? trim( $review['review_author'] ) : '';
|
||
// review_text — текст отзыва
|
||
$body = isset( $review['review_text'] ) ? trim( $review['review_text'] ) : '';
|
||
// review_rating — оценка 1-5
|
||
$rating_val = isset( $review['review_rating'] ) ? intval( $review['review_rating'] ) : 0;
|
||
|
||
// Пропускаем без автора или текста
|
||
if ( empty( $author_name ) || empty( $body ) ) {
|
||
continue;
|
||
}
|
||
|
||
// Фильтруем оценки вне диапазона 1-5
|
||
if ( $rating_val < 1 || $rating_val > 5 ) {
|
||
continue;
|
||
}
|
||
|
||
$review_seq++;
|
||
|
||
$entity = array(
|
||
'@type' => 'Review',
|
||
'@id' => $page_url . '#review-' . $review_seq,
|
||
'author' => array(
|
||
'@type' => 'Person',
|
||
'name' => wp_strip_all_tags( $author_name ),
|
||
),
|
||
'reviewBody' => wp_strip_all_tags( $body ),
|
||
'itemReviewed' => array(
|
||
'@id' => $page_url . '#course',
|
||
),
|
||
'reviewRating' => array(
|
||
'@type' => 'Rating',
|
||
'ratingValue' => (string) $rating_val,
|
||
'bestRating' => '5',
|
||
'worstRating' => '1',
|
||
),
|
||
);
|
||
|
||
// datePublished — дата отзыва (условно, из review_date)
|
||
$review_date = isset( $review['review_date'] ) ? trim( $review['review_date'] ) : '';
|
||
if ( $review_date ) {
|
||
$entity['datePublished'] = sanitize_text_field( $review_date );
|
||
}
|
||
|
||
$result[] = $entity;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Person[] — преподаватели из pksh_teachers complex.
|
||
* Каждый преподаватель — отдельная сущность Person.
|
||
* Связаны с LocalBusiness через employee @id.
|
||
*
|
||
* @return array[] Массив Person-сущностей для @graph.
|
||
* @see https://schema.org/Person
|
||
*/
|
||
function dekart_schema_teachers(): array {
|
||
$page_url = dekart_schema_page_url();
|
||
// pksh_teachers — complex-поле преподавателей
|
||
// Subfields: teacher_id (select → post ID), teacher_subject, teacher_about
|
||
// teacher_name и teacher_photo загружаются из карточки преподавателя
|
||
$teachers = carbon_get_post_meta( get_the_ID(), 'pksh_teachers' );
|
||
|
||
if ( empty( $teachers ) || ! is_array( $teachers ) ) {
|
||
return array();
|
||
}
|
||
|
||
$result = array();
|
||
foreach ( $teachers as $i => $teacher ) {
|
||
// teacher_id — ID записи преподавателя из кастомного типа
|
||
$t_id = isset( $teacher['teacher_id'] ) ? intval( $teacher['teacher_id'] ) : 0;
|
||
// teacher_subject — предмет / должность (с этой страницы)
|
||
$subject = isset( $teacher['teacher_subject'] ) ? trim( $teacher['teacher_subject'] ) : '';
|
||
// teacher_about — описание / биография (с этой страницы)
|
||
$about = isset( $teacher['teacher_about'] ) ? trim( $teacher['teacher_about'] ) : '';
|
||
|
||
if ( $t_id < 1 ) {
|
||
continue;
|
||
}
|
||
|
||
// Загружаем имя и фото из карточки преподавателя
|
||
$t_post = get_post( $t_id );
|
||
if ( ! $t_post ) {
|
||
continue;
|
||
}
|
||
$name = $t_post->post_title;
|
||
|
||
if ( empty( $name ) ) {
|
||
continue;
|
||
}
|
||
|
||
// Фото: _teacher_avatar → _thumbnail_id → 0
|
||
$photo = get_post_meta( $t_id, '_teacher_avatar', true );
|
||
if ( ! $photo ) {
|
||
$photo = get_post_meta( $t_id, '_thumbnail_id', true );
|
||
}
|
||
|
||
// Если subject/about не заданы на странице — fallback из карточки
|
||
if ( empty( $subject ) ) {
|
||
$subject = get_post_meta( $t_id, '_teacher_direction', true );
|
||
}
|
||
if ( empty( $about ) ) {
|
||
$about = get_post_meta( $t_id, '_teacher_about', true );
|
||
}
|
||
|
||
$person = array(
|
||
'@type' => 'Person',
|
||
'@id' => $page_url . '#teacher-' . $i,
|
||
'name' => wp_strip_all_tags( $name ),
|
||
);
|
||
|
||
// jobTitle — должность (условно)
|
||
if ( ! empty( $subject ) && is_string( $subject ) ) {
|
||
$person['jobTitle'] = wp_strip_all_tags( trim( $subject ) );
|
||
}
|
||
|
||
// description — о преподавателе (условно)
|
||
if ( ! empty( $about ) && is_string( $about ) ) {
|
||
$person['description'] = wp_strip_all_tags( trim( $about ) );
|
||
}
|
||
|
||
// image — фото преподавателя (условно)
|
||
if ( ! empty( $photo ) ) {
|
||
$url = wp_get_attachment_url( (int) $photo );
|
||
if ( $url ) {
|
||
$person['image'] = esc_url( $url );
|
||
}
|
||
}
|
||
|
||
$result[] = $person;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/* =====================================================================
|
||
Новые сущности для Phase 2 (Local SEO + AI)
|
||
===================================================================== */
|
||
|
||
/**
|
||
* Event — бесплатное пробное занятие.
|
||
* Google Events carousel, высокая конверсия на "пробное занятие [город]".
|
||
*
|
||
* @see https://schema.org/Event
|
||
*/
|
||
function dekart_schema_trial_event(): ?array {
|
||
if ( ! is_page( 2 ) ) {
|
||
return null;
|
||
}
|
||
|
||
$page_url = dekart_schema_page_url();
|
||
|
||
// Проверяем, есть ли упоминание бесплатного пробного в подзаголовке
|
||
$subtitle = carbon_get_post_meta( get_the_ID(), 'banner_subtitle' );
|
||
$has_trial = ! empty( $subtitle ) && false !== mb_stripos( $subtitle, 'бесплатно' );
|
||
|
||
if ( ! $has_trial ) {
|
||
return null;
|
||
}
|
||
|
||
// Дата события: ближайший понедельник или "ежедневно"
|
||
$start_date = carbon_get_post_meta( get_the_ID(), 'pksh_trial_event_start_date' );
|
||
if ( empty( $start_date ) ) {
|
||
// Fallback: следующий понедельник
|
||
$start_date = date( 'c', strtotime( 'next monday 10:00' ) );
|
||
}
|
||
|
||
$end_date = carbon_get_post_meta( get_the_ID(), 'pksh_trial_event_end_date' );
|
||
if ( empty( $end_date ) ) {
|
||
$end_date = date( 'c', strtotime( $start_date . ' +1 hour' ) );
|
||
}
|
||
|
||
$location = array(
|
||
'@type' => 'Place',
|
||
'name' => 'Детский центр «Декарт»',
|
||
'address' => array(
|
||
'@type' => 'PostalAddress',
|
||
'streetAddress' => get_option( 'branch_address' ) ?: carbon_get_theme_option( 'site_address' ),
|
||
'addressLocality' => get_option( 'branch_city', 'Щёлково' ),
|
||
'addressRegion' => get_option( 'branch_region', 'Московская область' ),
|
||
'addressCountry' => 'RU',
|
||
),
|
||
);
|
||
|
||
return array(
|
||
'@type' => 'Event',
|
||
'@id' => $page_url . '#trial-event',
|
||
'name' => 'Бесплатное пробное занятие: Подготовка к школе',
|
||
'description' => 'Полноценное занятие + диагностика готовности к школе. Ребёнок познакомится с педагогом, выполнит тестовые задания. Родители получат план подготовки.',
|
||
'eventStatus' => 'https://schema.org/EventScheduled',
|
||
'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
|
||
'startDate' => $start_date,
|
||
'endDate' => $end_date,
|
||
'location' => $location,
|
||
'offers' => array(
|
||
'@type' => 'Offer',
|
||
'@id' => $page_url . '#offer-trial',
|
||
'price' => '0',
|
||
'priceCurrency' => 'RUB',
|
||
'availability' => 'https://schema.org/LimitedAvailability',
|
||
'description' => 'Бесплатное пробное занятие',
|
||
'category' => 'Бесплатное пробное занятие',
|
||
),
|
||
'organizer' => array(
|
||
'@id' => home_url( '/' ) . '#organization',
|
||
),
|
||
'image' => get_the_post_thumbnail_url( 2, 'full' ) ?: '',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Offer / AggregateOffer с UnitPriceSpecification.
|
||
* Добавляет priceSpecification с unitCode: 'MON' для цен за месяц.
|
||
*
|
||
* @see https://schema.org/UnitPriceSpecification
|
||
*/
|
||
function dekart_schema_offer(): ?array {
|
||
$page_url = dekart_schema_page_url();
|
||
$programs = carbon_get_post_meta( get_the_ID(), 'pksh_programs' );
|
||
|
||
$price_valid = '';
|
||
if ( ! empty( $programs ) && is_array( $programs ) ) {
|
||
foreach ( $programs as $prog ) {
|
||
if ( ! empty( $prog['program_price_valid'] ) ) {
|
||
$d = trim( $prog['program_price_valid'] );
|
||
if ( $d > $price_valid ) {
|
||
$price_valid = $d;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$prices = array();
|
||
|
||
if ( ! empty( $programs ) && is_array( $programs ) ) {
|
||
foreach ( $programs as $program ) {
|
||
if ( ! empty( $program['program_price_subscription'] ) ) {
|
||
$num = dekart_parse_price( $program['program_price_subscription'] );
|
||
if ( $num > 0 ) {
|
||
$prices[] = $num;
|
||
}
|
||
}
|
||
if ( ! empty( $program['program_price_single'] ) ) {
|
||
$num = dekart_parse_price( $program['program_price_single'] );
|
||
if ( $num > 0 ) {
|
||
$prices[] = $num;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if ( empty( $prices ) ) {
|
||
$offer = array(
|
||
'@type' => 'Offer',
|
||
'@id' => $page_url . '#offer',
|
||
'price' => '0',
|
||
'priceCurrency' => 'RUB',
|
||
'availability' => 'https://schema.org/LimitedAvailability',
|
||
'description' => 'Бесплатное пробное занятие',
|
||
'category' => 'Бесплатное пробное занятие',
|
||
);
|
||
if ( ! empty( $price_valid ) ) {
|
||
$offer['priceValidUntil'] = trim( $price_valid );
|
||
}
|
||
return $offer;
|
||
}
|
||
|
||
$low = min( $prices );
|
||
$high = max( $prices );
|
||
|
||
$aggregate = array(
|
||
'@type' => 'AggregateOffer',
|
||
'@id' => $page_url . '#offer',
|
||
'priceCurrency' => 'RUB',
|
||
'lowPrice' => (string) $low,
|
||
'highPrice' => (string) $high,
|
||
'offerCount' => (string) count( $prices ),
|
||
'availability' => 'https://schema.org/InStock',
|
||
'category' => 'Образовательные услуги',
|
||
'offeredBy' => array(
|
||
'@id' => $page_url . '#localbusiness',
|
||
),
|
||
// UnitPriceSpecification для цен за месяц
|
||
'priceSpecification' => array(
|
||
'@type' => 'UnitPriceSpecification',
|
||
'priceCurrency' => 'RUB',
|
||
'minPrice' => (string) $low,
|
||
'maxPrice' => (string) $high,
|
||
'unitCode' => 'MON', // месяц
|
||
'unitText' => 'месяц',
|
||
),
|
||
);
|
||
|
||
if ( ! empty( $price_valid ) ) {
|
||
$aggregate['priceValidUntil'] = trim( $price_valid );
|
||
}
|
||
|
||
$offers = array( $aggregate );
|
||
|
||
$subtitle = carbon_get_post_meta( get_the_ID(), 'banner_subtitle' );
|
||
if ( ! empty( $subtitle ) && false !== mb_stripos( $subtitle, 'бесплатно' ) ) {
|
||
$trial = array(
|
||
'@type' => 'Offer',
|
||
'@id' => $page_url . '#offer-trial',
|
||
'price' => '0',
|
||
'priceCurrency' => 'RUB',
|
||
'availability' => 'https://schema.org/LimitedAvailability',
|
||
'description' => wp_strip_all_tags( $subtitle ),
|
||
'category' => 'Бесплатное пробное занятие',
|
||
'offeredBy' => array(
|
||
'@id' => $page_url . '#localbusiness',
|
||
),
|
||
);
|
||
if ( ! empty( $price_valid ) ) {
|
||
$trial['priceValidUntil'] = trim( $price_valid );
|
||
}
|
||
$offers[] = $trial;
|
||
}
|
||
|
||
return $offers;
|
||
}
|
||
|
||
/* =====================================================================
|
||
Вспомогательные функции
|
||
===================================================================== */
|
||
|
||
/**
|
||
* Парсит цену из форматированной строки (например "7 500 ₽" → 7500).
|
||
* Поддерживает "1 234,56" (comma-decimal) и "1,234.56" (dot-decimal).
|
||
*
|
||
* @param string $raw Строка с ценой.
|
||
* @return float Числовое значение.
|
||
*/
|
||
function dekart_parse_price( string $raw ): float {
|
||
// Удаляем всё кроме цифр, точки, запятой
|
||
$clean = preg_replace( '/[^0-9.,]/', '', $raw );
|
||
|
||
// Определяем формат: если последний разделитель — запятая → comma-decimal
|
||
$last_comma = strrpos( $clean, ',' );
|
||
$last_dot = strrpos( $clean, '.' );
|
||
|
||
if ( false !== $last_comma && ( false === $last_dot || $last_comma > $last_dot ) ) {
|
||
// Европейский формат: 1.234,56 → убираем точки, меняем запятую на точку
|
||
$clean = str_replace( '.', '', $clean );
|
||
$clean = str_replace( ',', '.', $clean );
|
||
} else {
|
||
// Стандартный/русский: убираем всё кроме последней точки
|
||
$clean = str_replace( ',', '', $clean );
|
||
$clean = preg_replace( '/\.(?=.*\.)/', '', $clean );
|
||
}
|
||
|
||
return floatval( $clean );
|
||
}
|