МЕРА B2C: микроразметка schema.org и картинки превью ссылок #3438
17 changed files with 764 additions and 12 deletions
|
|
@ -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/, а не аккуратность этого
|
||||
|
|
|
|||
|
|
@ -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: прежний матчер
|
||||
|
|
|
|||
BIN
tradein-mvp/frontend/public/logo-mera.png
Normal file
BIN
tradein-mvp/frontend/public/logo-mera.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
BIN
tradein-mvp/frontend/public/og-mera.png
Normal file
BIN
tradein-mvp/frontend/public/og-mera.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
87
tradein-mvp/frontend/scripts/og/logo-mera.html
Normal file
87
tradein-mvp/frontend/scripts/og/logo-mera.html
Normal 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>
|
||||
264
tradein-mvp/frontend/scripts/og/og-mera.html
Normal file
264
tradein-mvp/frontend/scripts/og/og-mera.html
Normal 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">Реальная цена вашей квартиры — чтобы не продешевить</div>
|
||||
<div class="lead">Сделки Росреестра и объявления площадок — по вашему адресу.</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>
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
149
tradein-mvp/frontend/src/app/mera-public/_lib/schema.ts
Normal file
149
tradein-mvp/frontend/src/app/mera-public/_lib/schema.ts
Normal 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"),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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="Хлебные крошки">
|
||||
|
|
|
|||
|
|
@ -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="Хлебные крошки">
|
||||
|
|
|
|||
|
|
@ -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="Хлебные крошки">
|
||||
|
|
|
|||
|
|
@ -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) и в политике возврата —
|
||||
* поэтому живёт здесь, а не литералом в двух документах сразу.
|
||||
|
|
|
|||
|
|
@ -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="Хлебные крошки">
|
||||
|
|
|
|||
|
|
@ -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, а не в корневом
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue