МЕРА B2C: микроразметка schema.org и картинки превью ссылок #3438

Merged
lekss361 merged 1 commit from feat/mera-schema-og into main 2026-09-10 16:38:51 +00:00
17 changed files with 764 additions and 12 deletions

View file

@ -598,6 +598,36 @@ meraocenka.ru {
}
}
# og-mera.png — картинка превью ссылок (og:image). Её просят НЕ браузеры
# посетителей, а обходчики мессенджеров и соцсетей (Telegram, VK,
# WhatsApp), причём анонимно и без Referer: если путь не открыт, ссылка на
# сайт разворачивается без картинки, и понять это по логам приложения
# нельзя — запрос просто не доходит.
#
# Отдельный handle по той же причине, что у robots.txt: файл лежит в
# `public/` фронта и раздаётся Next'ом по basePath-корню
# (/trade-in/og-mera.png), а не под /trade-in/mera-public{path}, куда
# рерайтит @meraPages. Адрес намеренно короткий: он попадает в разметку
# каждой публичной страницы и расходится по чужим кэшам, так что менять
# его потом дорого.
handle /og-mera.png {
rewrite * /trade-in/og-mera.png
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# logo-mera.png — логотип организации из микроразметки (Organization.logo).
# Отдельный файл от og-mera.png выше: превью ссылки широкое, а логотип
# поисковик обрезает близко к квадрату. Свой handle по той же причине —
# файл лежит в `public/` и раздаётся по basePath-корню.
handle /logo-mera.png {
rewrite * /trade-in/logo-mera.png
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до
# бэкенда. Под /api/public/ по определению не лежит ничего закрытого:
# гарантию даёт структура пакета app/api/public/, а не аккуратность этого

View file

@ -125,6 +125,28 @@ check_redirect_location() {
esac
}
# check_content_type: 200 И заявленный Content-Type начинается с ожидаемого.
#
# Заведён под og:image. Для картинки превью «200» само по себе ничего не
# доказывает: перепутанный rewrite приводит к 200 с HTML-страницей фронта, и
# обходчик мессенджера её молча отбросит — ссылка развернётся без картинки, а
# смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт.
check_content_type() {
local desc="$1" url="$2" expected_prefix="$3"
local out code ctype
out=$(curl -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
code=${out##*$'
'}
ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d '
' | sed 's/^[Cc]ontent-[Tt]ype: *//' | head -1)
if [ "$code" = "200" ] && [ "${ctype#"$expected_prefix"}" != "$ctype" ]; then
echo "PASS: $desc ($url -> $code, $ctype)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}' / '${ctype:-<no content-type>}', expected 200 + $expected_prefix)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
@ -164,6 +186,14 @@ check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200
check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200
# 1d4. og:image — картинка превью ссылок. Её просят обходчики мессенджеров и
# соцсетей, анонимно и без Referer; отвалится handle — ссылки на сайт
# начнут разворачиваться без картинки, и заметить это по логам
# приложения нельзя (запрос туда просто не доходит). Content-Type
# проверяется вместе с кодом: 200 с HTML вместо PNG выглядел бы так же.
check_content_type "meraocenka.ru/og-mera.png — public 200 + image/png" "$BASE_MERA/og-mera.png" "image/png"
check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BASE_MERA/logo-mera.png" "image/png"
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

@ -0,0 +1,87 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Логотип МЕРА для микроразметки</title>
<!--
ИСХОДНИК ЛОГОТИПА ДЛЯ МИКРОРАЗМЕТКИ (Organization.logo), 512x512.
Отсюда рендерится `public/logo-mera.png`. Нужен ОТДЕЛЬНО от og-mera.png:
превью ссылки — широкая карточка 1200x630, а логотип организации поисковики
показывают в карточке компании и обрезают близко к квадрату, поэтому баннер
на его месте выглядел бы обрезанной надписью.
КАК ПЕРЕРИСОВАТЬ (нужен установленный Chrome/Chromium):
chrome --headless --disable-gpu --hide-scrollbars \
--screenshot=public/logo-mera.png --window-size=512,512 \
scripts/og/logo-mera.html
Цвета — копия b2c-tokens.ts, ровно по тем же причинам, что расписаны в
шапке og-mera.html.
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@800&family=IBM+Plex+Mono:wght@500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 512px; height: 512px; overflow: hidden; }
body {
background: #FFFFFF;
font-family: "Manrope", system-ui, sans-serif;
color: #121A1D;
-webkit-font-smoothing: antialiased;
display: grid;
place-items: center;
}
.mark {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
}
/* Тот же знак, что в шапке сайта (HeaderV3): контур с точкой внутри. */
.icon {
width: 168px;
height: 168px;
border: 9px solid #16A9BC;
border-radius: 26px;
display: grid;
place-items: center;
}
.dot {
width: 56px;
height: 56px;
background: #16A9BC;
border-radius: 11px;
}
.name {
font-weight: 800;
font-size: 76px;
letter-spacing: 0.06em;
line-height: 1;
}
.sub {
margin-top: -18px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-weight: 500;
font-size: 20px;
letter-spacing: 0.18em;
color: #5B686C;
}
</style>
</head>
<body>
<div class="mark">
<div class="icon"><div class="dot"></div></div>
<div class="name">МЕРА</div>
<div class="sub">ДЛЯ СЕБЯ</div>
</div>
</body>
</html>

View file

@ -0,0 +1,264 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>OG-карточка МЕРА</title>
<!--
ИСХОДНИК КАРТИНКИ ДЛЯ ПРЕВЬЮ ССЫЛОК (og:image), 1200x630.
Отсюда рендерится `public/og-mera.png` — тот файл и уходит в мессенджеры и
соцсети. Держим исходник в репозитории, чтобы картинку можно было
перерисовать, а не только переоткрыть в графическом редакторе.
КАК ПЕРЕРИСОВАТЬ (нужен установленный Chrome/Chromium):
chrome --headless --disable-gpu --hide-scrollbars \
--screenshot=public/og-mera.png --window-size=1200,630 \
scripts/og/og-mera.html
ПОЧЕМУ ГОТОВЫЙ PNG, А НЕ `opengraph-image.tsx` (ImageResponse из next/og).
Тот путь красивее по духу — картинка собиралась бы из кода на каждой сборке,
— но у него два практических изъяна ровно для нашего случая. Первый: satori
внутри ImageResponse рисует ТОЛЬКО теми шрифтами, которые ему передали
байтами, а дефолтный набор кириллицы не покрывает — пришлось бы положить
.ttf в репозиторий и читать его в рантайме. Второй: satori понимает лишь
подмножество CSS и не видит ни CSS Modules, ни наших `--b2c-*` переменных,
так что «те же токены, что на сайте» там всё равно превратились бы в
отдельный набор литералов. Готовый PNG отдаётся статикой, кэшируется
навсегда и не стоит ничего в рантайме.
ЦВЕТА ЗДЕСЬ ЛИТЕРАЛАМИ — это единственное место в публичном дереве, где так
можно: файл не участвует в сборке фронта, `var(--b2c-*)` в нём резолвить
некому. Значения обязаны совпадать с `src/app/mera-public/b2c-tokens.ts`;
меняются токены — меняется и эта карточка, руками.
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;800&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* Значения — копия b2c-tokens.ts (см. комментарий выше). */
:root {
--page-bg: #F4F7F8;
--surface: #FFFFFF;
--surface-soft: #EAF5F7;
--ink: #121A1D;
--muted: #5B686C;
--line: #CBD7DA;
--accent: #16A9BC;
--accent-text: #0B6B79;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1200px;
height: 630px;
overflow: hidden;
}
body {
background: var(--page-bg);
font-family: "Manrope", system-ui, sans-serif;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
.card {
position: relative;
width: 1200px;
height: 630px;
padding: 60px 72px 56px;
display: grid;
grid-template-columns: 640px 1fr;
grid-template-rows: auto 1fr auto;
column-gap: 56px;
align-items: start;
}
/* Акцентная полоса по левому краю — тот же приём, что у секций лэндинга. */
.card::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 10px;
background: var(--accent);
}
/* --- шапка: знак и словесный логотип, как в HeaderV3 --- */
.logo {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 14px;
}
.logoIcon {
width: 46px;
height: 46px;
border: 2.5px solid var(--accent);
border-radius: 7px;
display: grid;
place-items: center;
}
.logoDot {
width: 15px;
height: 15px;
background: var(--accent);
border-radius: 3px;
}
.logoText { display: flex; flex-direction: column; line-height: 1; }
.logoName { font-weight: 800; font-size: 30px; letter-spacing: 0.06em; }
.logoSub {
margin-top: 5px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 14px;
letter-spacing: 0.18em;
color: var(--muted);
}
/* --- середина слева: заголовок и подзаголовок --- */
.pitch { align-self: center; }
.headline {
font-size: 58px;
line-height: 1.1;
font-weight: 800;
letter-spacing: -0.02em;
}
.lead {
margin-top: 24px;
font-size: 25px;
line-height: 1.4;
font-weight: 500;
color: var(--muted);
}
/* --- середина справа: диапазон цены. Продукт называет ВИЛКУ, а не одно
число, и картинка обязана говорить то же самое. --- */
.range {
align-self: center;
padding: 28px 30px 32px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 18px;
}
.rangeLabel {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 13px;
letter-spacing: 0.16em;
color: var(--muted);
}
.rangeBar {
margin-top: 22px;
height: 12px;
border-radius: 6px;
background: var(--surface-soft);
position: relative;
}
.rangeFill {
position: absolute;
left: 18%;
right: 22%;
top: 0;
bottom: 0;
border-radius: 6px;
background: var(--accent);
}
.rangeMark {
position: absolute;
left: 52%;
top: -7px;
width: 4px;
height: 26px;
border-radius: 2px;
background: var(--ink);
}
.rangeEnds {
margin-top: 16px;
display: flex;
justify-content: space-between;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 15px;
font-weight: 500;
color: var(--ink);
}
.rangeNote {
margin-top: 18px;
font-size: 15px;
line-height: 1.45;
color: var(--muted);
}
/* --- подвал: регион слева, домен справа --- */
.foot {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
.region {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 17px;
letter-spacing: 0.14em;
color: var(--muted);
}
.domain {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 24px;
font-weight: 500;
color: var(--accent-text);
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logoIcon"><div class="logoDot"></div></div>
<div class="logoText">
<div class="logoName">МЕРА</div>
<div class="logoSub">ДЛЯ СЕБЯ</div>
</div>
</div>
<div class="pitch">
<!-- Дословно h1 лэндинга (HeroV3.tsx) — превью не должно обещать
другого, чем страница, на которую ведёт ссылка. -->
<div class="headline">Реальная цена вашей квартиры&nbsp;— чтобы не продешевить</div>
<div class="lead">Сделки Росреестра и объявления площадок&nbsp;— по вашему адресу.</div>
</div>
<div class="range">
<div class="rangeLabel">ДИАПАЗОН ЦЕНЫ</div>
<div class="rangeBar">
<div class="rangeFill"></div>
<div class="rangeMark"></div>
</div>
<div class="rangeEnds"><span>ниже</span><span>выше</span></div>
<!-- Ни одного числа: любая подставленная сумма читалась бы как обещание
конкретной оценки. Показываем форму ответа, а не выдуманный ответ. -->
<div class="rangeNote">Вилка, а не одно число: рядом видно, на скольких объектах она построена.</div>
</div>
<div class="foot">
<div class="region">СВЕРДЛОВСКАЯ ОБЛАСТЬ</div>
<div class="domain">meraocenka.ru</div>
</div>
</div>
</body>
</html>

View file

@ -183,7 +183,20 @@ describe("короткие адреса публичного домена", () =
* `/trade-in/...`-хопы это внутренняя проводка basePath, а не публичная
* поверхность, у них своя проверка ниже (B2B-пути обязаны отдавать 404).
*/
const ROOT_HANDLES_ALLOWED = ["/metrics", "/favicon.ico", "/robots.txt"];
const ROOT_HANDLES_ALLOWED = [
"/metrics",
"/favicon.ico",
"/robots.txt",
// Картинка превью ссылок (og:image), 10.09.2026. Её просят обходчики
// мессенджеров и соцсетей, а не посетители; файл лежит в `public/` фронта
// и раздаётся по basePath-корню, поэтому rewrite @meraPages его не
// обслуживает — отсюда свой handle, как у robots.txt.
"/og-mera.png",
// Логотип организации из микроразметки (Organization.logo), 10.09.2026.
// Отдельный файл от превью: оно широкое, логотип поисковик обрезает
// близко к квадрату. Раздаётся так же — из `public/`, своим handle.
"/logo-mera.png",
];
it("корневые handle публичного домена — только объявленные", () => {
const declared = [...meraBlock.matchAll(/^\s*handle\s+(\/[^\s*{]+)\s*\{/gm)]

View file

@ -0,0 +1,18 @@
/**
* JsonLd серверный компонент, печатающий один узел/граф schema.org в
* `<script type="application/ld+json">`. Без "use client": разметка не
* зависит от рантайма браузера и должна попасть в тот же серверный HTML, что
* и остальная страница (поисковик её не выполняет, а парсит статически).
*/
export function JsonLd({ data }: { data: object }) {
return (
// dangerouslySetInnerHTML тут безопасен: содержимое — JSON.stringify
// объекта, собранного этим же файлом из констант, а не из
// пользовательского ввода (в отличие от safeUrl-кейсов для href/src,
// см. .claude/rules/frontend.md).
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View file

@ -60,8 +60,10 @@
*/
import { FAQ } from "../../content";
import { faqNode } from "../../_lib/schema";
import styles from "../../landing-v3.module.css";
import { JsonLd } from "../JsonLd";
import { PublicLink } from "../PublicLink";
interface Objection {
@ -109,6 +111,17 @@ const ITEMS: readonly Objection[] = [
...fromFaq("why-region"),
];
/**
* `FAQPage` строится из ровно того же `ITEMS`, что рендерится ниже в
* `<details>` не из `FAQ` напрямую (в `ITEMS` часть вопросов замещена
* макетными формулировками, см. шапку файла), иначе видимый текст и
* разметка молча разъехались бы.
*/
const FAQ_JSON_LD = {
"@context": "https://schema.org",
...faqNode(ITEMS),
};
/**
* Отсылка к странице про обработку персональных данных внутри ответа
* `personal-data`. В `content.ts` она лежит обычным текстом (там нет разметки),
@ -141,6 +154,7 @@ function renderAnswer(text: string) {
export function ObjectionsV3() {
return (
<section className={styles.objSection} aria-labelledby="obj-v3-title">
<JsonLd data={FAQ_JSON_LD} />
<div className={styles.objGrid}>
<div>
<div className={styles.accEyebrow}>СОМНЕНИЯ</div>

View file

@ -0,0 +1,149 @@
/**
* schema конструкторы JSON-LD (schema.org) для публичного B2C-периметра
* «МЕРА». Чистые функции, возвращающие обычные объекты: ни React, ни
* next/*, ни импортов из закрытого B2B-контура этот файл сам входит в
* дерево `mera-public/**`, которое гейтится
* `scripts/check-mera-public-isolation.mjs`, поэтому имеет право
* импортировать только `../content` (и стандартную библиотеку).
*
* ПРАВИЛО ЧЕСТНОСТИ действует и здесь так же, как в content.ts: разметка не
* должна заявлять того, чего код не делает ни оценок, ни рейтингов, ни дат
* без источника, ни доступности покупки, которой ещё нет.
*/
import {
LEGAL_ENTITY,
LOGO_IMAGE_URL,
OG_IMAGE_URL,
PUBLIC_ESTIMATE_ENABLED,
PUBLIC_ESTIMATE_URL,
PUBLIC_ORIGIN,
REGION_NAME,
SERVICE_PRICE_RUB,
SUPPORT_EMAIL,
} from "../content";
/**
* Стабильные `@id` организации и сайта. Остальные узлы графа (Service,
* Article, ...) ссылаются на них через `{ "@id": ... }`, а не дублируют
* `Organization`/`WebSite` целиком иначе один и тот же адрес/почта
* разъедутся между узлами при следующей правке content.ts.
*/
export const ORGANIZATION_ID = `${PUBLIC_ORIGIN}/#organization`;
export const WEBSITE_ID = `${PUBLIC_ORIGIN}/#website`;
/**
* `Organization` юридическое лицо за сервисом. `address` собирается ТОЛЬКО
* если `LEGAL_ENTITY !== null`: это тот же продуктовый гейт платного контура
* (G6), что стоит перед реквизитами в content.ts нет реквизитов, нет и
* адреса в разметке, а не выдуманная строка.
*/
export function organizationNode() {
return {
"@type": "Organization",
"@id": ORGANIZATION_ID,
name: "МЕРА",
legalName: LEGAL_ENTITY?.name,
...(LEGAL_ENTITY
? {
address: {
"@type": "PostalAddress",
addressCountry: "RU",
streetAddress: LEGAL_ENTITY.address,
},
}
: {}),
url: PUBLIC_ORIGIN,
email: SUPPORT_EMAIL,
taxID: LEGAL_ENTITY?.inn,
identifier: LEGAL_ENTITY?.ogrn,
areaServed: REGION_NAME,
logo: LOGO_IMAGE_URL,
image: OG_IMAGE_URL,
};
}
/**
* `WebSite` БЕЗ `potentialAction`/`SearchAction`: на сайте нет поиска по
* содержимому (есть только форма проверки адреса), а разметка обязана
* описывать то, что реально есть, а не то, что обычно кладут в шаблон.
*/
export function websiteNode() {
return {
"@type": "WebSite",
"@id": WEBSITE_ID,
url: PUBLIC_ORIGIN,
name: "МЕРА",
inLanguage: "ru-RU",
publisher: { "@id": ORGANIZATION_ID },
};
}
/**
* `Service` платный расчёт стоимости квартиры. `availability` вычисляется
* из `PUBLIC_ESTIMATE_ENABLED`, а не захардкожена в `InStock`: приём оплаты
* ещё не включён (см. content.ts), и разметка не должна обещать покупку,
* которой на странице нет иначе первое же нажатие «купить» несуществующей
* кнопки было бы прямым расхождением между JSON-LD и фактическим состоянием
* продукта.
*/
export function serviceNode() {
return {
"@type": "Service",
name: "Платный расчёт стоимости квартиры «МЕРА»",
serviceType: "Оценка рыночной стоимости недвижимости",
provider: { "@id": ORGANIZATION_ID },
areaServed: { "@type": "State", name: REGION_NAME },
offers: {
"@type": "Offer",
price: SERVICE_PRICE_RUB,
priceCurrency: "RUB",
url: PUBLIC_ESTIMATE_URL,
availability: PUBLIC_ESTIMATE_ENABLED
? "https://schema.org/InStock"
: "https://schema.org/PreOrder",
},
};
}
/**
* `BreadcrumbList` от `position: 1` (Главная) до текущей страницы
* включительно. Последний элемент тоже получает `item`/`url`: рекомендация
* schema.org допускает опускать `item` у текущей страницы, но URL у неё есть
* всегда (это публичный, индексируемый адрес), и явное поле надёжнее
* умолчания поисковика.
*/
export function breadcrumbNode(
items: readonly { name: string; url: string }[],
) {
return {
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: item.url,
})),
};
}
/**
* `FAQPage` строится из ТЕХ ЖЕ данных, что рендерятся на странице (вызывающая
* сторона обязана передать ровно тот массив, что уходит в `<details>`), иначе
* видимый текст и разметка расходятся молча. Абзацы ответа склеиваются через
* пустую строку так же, как их разделяют отдельные `<p>` в разметке.
*/
export function faqNode(
items: readonly { q: string; a: readonly string[] }[],
) {
return {
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.q,
acceptedAnswer: {
"@type": "Answer",
text: item.a.join("\n\n"),
},
})),
};
}

View file

@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { InnerHeader } from "../../_components/InnerHeader";
import { JsonLd } from "../../_components/JsonLd";
import { PublicLink } from "../../_components/PublicLink";
import { FooterV3 } from "../../_components/v3/FooterV3";
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
import styles from "../../landing-v3.module.css";
import { ARTICLE_CARDS, HOWTO } from "../articles-content";
@ -46,21 +48,24 @@ const ARTICLE_JSON_LD = {
headline: HOWTO.title,
description: DESCRIPTION,
author: { "@type": "Organization", name: "МЕРА" },
publisher: { "@type": "Organization", name: "МЕРА" },
publisher: { "@id": ORGANIZATION_ID },
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_HOWTO_URL },
};
const BREADCRUMB_JSON_LD = {
"@context": "https://schema.org",
...breadcrumbNode([
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
{ name: HOWTO.title, url: PUBLIC_HOWTO_URL },
]),
};
export default function HowToArticlePage() {
return (
<>
{/* dangerouslySetInnerHTML тут безопасен: содержимое JSON.stringify
объекта, собранного этим же файлом из констант, а не из
пользовательского ввода (в отличие от safeUrl-кейсов для href/src,
см. .claude/rules/frontend.md). */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ARTICLE_JSON_LD) }}
/>
<JsonLd data={ARTICLE_JSON_LD} />
<JsonLd data={BREADCRUMB_JSON_LD} />
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">

View file

@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { InnerHeader } from "../_components/InnerHeader";
import { JsonLd } from "../_components/JsonLd";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import { breadcrumbNode } from "../_lib/schema";
import { PUBLIC_ORIGIN, PUBLIC_ROUTES, SERVICE_PRICE_RUB } from "../content";
import styles from "../landing-v3.module.css";
import { ARTICLE_CARDS } from "./articles-content";
@ -11,6 +13,14 @@ const PUBLIC_ARTICLES_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}`;
const DESCRIPTION =
"Как отбирать аналоги, считать цену за метр и проверять её сроком продажи — разборы на данных, а не на мнениях.";
const BREADCRUMB_JSON_LD = {
"@context": "https://schema.org",
...breadcrumbNode([
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
{ name: "Статьи", url: PUBLIC_ARTICLES_URL },
]),
};
/**
* Список статей макет 29.08 («МЕРА статьи»).
*
@ -41,6 +51,7 @@ export const metadata: Metadata = {
export default function ArticlesPage() {
return (
<>
<JsonLd data={BREADCRUMB_JSON_LD} />
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">

View file

@ -3,8 +3,10 @@ import type { Metadata } from "next";
import { safeUrl } from "@/lib/safeUrl";
import { InnerHeader } from "../_components/InnerHeader";
import { JsonLd } from "../_components/JsonLd";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import { breadcrumbNode } from "../_lib/schema";
import {
B2B_URL,
PUBLIC_ORIGIN,
@ -55,11 +57,20 @@ export const metadata: Metadata = {
},
};
const BREADCRUMB_JSON_LD = {
"@context": "https://schema.org",
...breadcrumbNode([
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
{ name: "Для бизнеса", url: PUBLIC_BUSINESS_URL },
]),
};
export default function BusinessPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<>
<JsonLd data={BREADCRUMB_JSON_LD} />
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">

View file

@ -228,6 +228,39 @@ export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.oferta}`;
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.refund}`;
export const PUBLIC_ESTIMATE_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.estimate}`;
/**
* Картинка превью ссылки (og:image), 1200×630.
*
* ЛЕЖИТ В КОРНЕ ПУБЛИЧНОГО ДОМЕНА, А НЕ СРЕДИ МАРШРУТОВ. Файл раздаётся из
* `public/` фронта, короткий адрес открыт отдельным `handle` в Caddy по той
* же причине, что у robots.txt: у Next это basePath-корень
* (/trade-in/og-mera.png), а не поддерево mera-public, куда рерайтит
* @meraPages. В `PUBLIC_ROUTES` ему поэтому не место: там реестр СТРАНИЦ, и
* попадание туда сделало бы выразимой ссылку «перейти на картинку».
*
* АДРЕС МЕНЯТЬ ДОРОГО. Он уходит в разметку каждой публичной страницы и
* оседает в кэшах мессенджеров и соцсетей: старое превью живёт у них неделями
* после того, как файл переехал. Перерисовывать картинку по тому же адресу
* можно и нужно, переименовывать нет. Исходник и инструкция по перерисовке:
* `tradein-mvp/frontend/scripts/og/og-mera.html`.
*/
export const OG_IMAGE_PATH = "/og-mera.png";
export const OG_IMAGE_URL = `${PUBLIC_ORIGIN}${OG_IMAGE_PATH}`;
export const OG_IMAGE_WIDTH = 1200;
export const OG_IMAGE_HEIGHT = 630;
export const OG_IMAGE_ALT = "МЕРА — реальная цена вашей квартиры по рыночным данным";
/**
* Логотип для микроразметки организации (`Organization.logo`), 512×512.
*
* ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ OG_IMAGE. Превью ссылки широкая карточка 1200×630;
* логотип же поисковик показывает в карточке компании и обрезает близко к
* квадрату, так что баннер на его месте превратился бы в обрезок надписи.
* Раздаётся тем же способом и по тем же причинам, что og-mera.png выше.
*/
export const LOGO_IMAGE_PATH = "/logo-mera.png";
export const LOGO_IMAGE_URL = `${PUBLIC_ORIGIN}${LOGO_IMAGE_PATH}`;
/**
* Цена одного расчёта, . Названа в оферте (п. 4.1) и в политике возврата
* поэтому живёт здесь, а не литералом в двух документах сразу.

View file

@ -3,8 +3,10 @@ import type { Metadata } from "next";
import { safeUrl } from "@/lib/safeUrl";
import { InnerHeader } from "../_components/InnerHeader";
import { JsonLd } from "../_components/JsonLd";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import { breadcrumbNode } from "../_lib/schema";
import {
LEGAL_ENTITY,
PUBLIC_ORIGIN,
@ -82,11 +84,20 @@ const TARIFFS: readonly {
},
] as const;
const BREADCRUMB_JSON_LD = {
"@context": "https://schema.org",
...breadcrumbNode([
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
{ name: "Документы", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.docs}` },
]),
};
export default function DocsPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<>
<JsonLd data={BREADCRUMB_JSON_LD} />
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">

View file

@ -2,11 +2,33 @@ import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { AnalyticsScripts } from "./_components/AnalyticsScripts";
import { JsonLd } from "./_components/JsonLd";
import { CookieBanner } from "./_components/v3/CookieBanner";
import { PUBLIC_ORIGIN, REGION_NAME } from "./content";
import { organizationNode, websiteNode } from "./_lib/schema";
import {
OG_IMAGE_ALT,
OG_IMAGE_HEIGHT,
OG_IMAGE_URL,
OG_IMAGE_WIDTH,
PUBLIC_ORIGIN,
REGION_NAME,
} from "./content";
import styles from "./landing.module.css";
import { b2cVars, landingVars } from "./theme";
/**
* `Organization`/`WebSite` общий граф для ВСЕГО поддерева, а не только
* лэндинга: остальные страницы (статьи, документы, бизнес) ссылаются на
* `ORGANIZATION_ID` через `{ "@id": ... }` и не обязаны повторять реквизиты
* организации у себя. Ставится сюда же, рядом с AnalyticsScripts/CookieBanner
* (см. комментарий у div ниже) тот же div уже единственное место, где можно
* смонтировать что-то общее для ОБОИХ вариантов хрома страниц.
*/
const SITE_GRAPH = {
"@context": "https://schema.org",
"@graph": [organizationNode(), websiteNode()],
};
/**
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
* рендерил общий хром для всего поддерева. С появлением второй версии
@ -70,6 +92,20 @@ const plexMono = IBM_Plex_Mono({
const TITLE = "Мера · расчёт стоимости квартиры по рыночным данным";
const DESCRIPTION = `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`;
/**
* Картинка превью ссылки. Абсолютный адрес, а не относительный: `metadataBase`
* его и так достроил бы, но og:image читают чужие обходчики, у которых нашего
* контекста нет, и абсолютная форма здесь дешевле любой экономии. Размеры
* заявлены явно без них часть площадок скачивает файл ради определения
* геометрии и до отрисовки превью может не дойти.
*/
const OG_IMAGE = {
url: OG_IMAGE_URL,
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
alt: OG_IMAGE_ALT,
};
/**
* `verification` оба поля опциональны в Next: если добавить ключ со
* значением `""`, в HTML уйдёт пустой `<meta name="..." content="">`, а
@ -98,8 +134,21 @@ export const metadata: Metadata = {
title: TITLE,
description: DESCRIPTION,
url: PUBLIC_ORIGIN,
images: [OG_IMAGE],
},
/**
* `images` ПРОДУБЛИРОВАН В twitter НАМЕРЕННО. Next не переносит картинку из
* `openGraph` в twitter-теги, когда метаданные заданы объектом (перенос есть
* только у файловой конвенции opengraph-image). Без этой строки карточка
* заявлена как `summary_large_image`, а картинки в ней нет то есть тег
* обещает крупное превью и не даёт его.
*/
twitter: {
card: "summary_large_image",
title: TITLE,
description: DESCRIPTION,
images: [OG_IMAGE],
},
twitter: { card: "summary_large_image" },
...(Object.keys(verification).length > 0 ? { verification } : {}),
};
@ -121,6 +170,7 @@ export default function MeraPublicLayout({
<a className={styles.skipLink} href="#content">
Перейти к содержимому
</a>
<JsonLd data={SITE_GRAPH} />
{children}
{/* AnalyticsScripts/CookieBanner ВНУТРИ этого div, а не в корневом

View file

@ -13,9 +13,27 @@ import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view";
import { serviceNode } from "./_lib/schema";
import { fetchLandingStats, fetchShowcase } from "./public-api";
import styles from "./landing.module.css";
/**
* JSON-LD статичен (не зависит от `stats`/`showcase`) считается один раз
* на модуль, а не при каждом рендере страницы.
*
* Рендерится ниже сырым `<script>`, а не общим `_components/JsonLd`: этот
* файл единственный источник `V3_SOURCES` для гейта #2904
* (`__tests__/landing-numbers-gate.test.ts`), и его отдельная проверка
* требует, чтобы КАЖДЫЙ импорт корня из `_components/` лежал в каталоге
* `_components/v3` (auto-discovery) общий, неспецифичный для v3
* компонент туда класть не место. Сырой тег даёт тот же HTML, а описание
* его безопасности то же, что в `_components/JsonLd.tsx`.
*/
const SERVICE_JSON_LD = {
"@context": "https://schema.org",
...serviceNode(),
};
/**
* Публичный лэндинг «МЕРА» первое публичное лицо продукта.
*
@ -86,6 +104,14 @@ export default async function MeraPublicPage() {
return (
<>
{/* dangerouslySetInnerHTML тут безопасен: содержимое JSON.stringify
объекта, собранного этим же файлом из констант, а не из
пользовательского ввода (в отличие от safeUrl-кейсов для href/src,
см. .claude/rules/frontend.md). */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(SERVICE_JSON_LD) }}
/>
<HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}>
{deals.length > 0 && <DealsTickerV3 deals={deals} />}