'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 = '' . "\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' ) ?: '', ); } /** * QAPage — ключевые вопросы для AI citation readiness. * Google retired FAQ rich results May 2026, но QAPage важен для AI Overviews/ChatGPT/Perplexity. * * @see https://schema.org/QAPage */ function dekart_schema_qapage(): ?array { if ( ! is_page( 2 ) ) { return null; } $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; } // Берём только 3-5 ключевых вопросов (самые частые) $key_questions = array_slice( $faq_items, 0, 5 ); $main_entity = array(); foreach ( $key_questions 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' => 'QAPage', '@id' => $page_url . '#qapage', 'mainEntity' => $main_entity, ); } /** * 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 ); }