fix(mera): кликабельная ссылка на политику ПДн в чекбоксе согласия + синхронизация версии (блоки 2, 5.4) #2886

Merged
lekss361 merged 2 commits from fix/mera-consent-policy-link into main 2026-08-14 12:48:34 +00:00
6 changed files with 191 additions and 37 deletions

View file

@ -51,16 +51,24 @@ _PHONE_MAX_DIGITS = 15
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
# только в audit-лог (#2497 TODO, теперь закрыт).
_CONSENT_POLICY_VERSION = "2026-07"
#
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
_CONSENT_POLICY_VERSION = "2026-08-13"
# Снимок точного текста согласия, который видит пользователь при отправке лида.
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
# будут нести устаревший snapshot под новой version-меткой.
# Снимок точного текста согласия, который видит пользователь при отправке лида
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/
# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно
# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые
# строки будут нести устаревший snapshot под новой version-меткой.
_CONSENT_TEXT_SNAPSHOT = (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Федеральным законом «О персональных данных» № 152-ФЗ"
"Политикой обработки персональных данных"
)

View file

@ -15,8 +15,19 @@ WHAT:
LeadForm.tsx (regex, no JSX parser needed -- there is exactly one <span>
in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for-
byte after whitespace normalisation (JSX text nodes wrap across source
lines; the DOM-rendered text collapses that to single spaces). If someone
edits ONE side without the other, this test fails.
lines; the DOM-rendered text collapses that to single spaces). The label
now wraps a `<Link>` ("Политикой обработки персональных данных" is a
clickable link to the actual policy document, RKN/owner requirement --
the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only
the human-readable text, so the comparison stays a FLAT string on both
sides). If someone edits ONE side without the other, this test fails.
A second test (`test_consent_policy_version_matches_privacy_approval_date`)
guards the OTHER half of the same drift class found during triage: nothing
was checking that _CONSENT_POLICY_VERSION actually points at the privacy
policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts).
Bumping the policy text without bumping the version tag (or vice versa)
would silently mislabel every lead's proof-of-consent snapshot.
NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT
in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet
@ -34,25 +45,71 @@ from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_REPO_ROOT = Path(__file__).resolve().parents[2]
_FRONTEND_LEAD_FORM = (
Path(__file__).resolve().parents[2]
/ "frontend"
/ "src"
/ "components"
/ "trade-in"
/ "v2"
/ "LeadForm.tsx"
_REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx"
)
_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г.").
_RU_MONTHS_GENITIVE = {
"января": 1,
"февраля": 2,
"марта": 3,
"апреля": 4,
"мая": 5,
"июня": 6,
"июля": 7,
"августа": 8,
"сентября": 9,
"октября": 10,
"ноября": 11,
"декабря": 12,
}
def _extract_span_text(tsx_source: str) -> str:
"""Pull the text content of the (single) <span>...</span> in LeadForm.tsx,
whitespace-normalised the same way a browser collapses JSX text-node
whitespace when rendering (multiple lines/indentation -> single spaces).
The span may contain nested JSX markup (e.g. a <Link> wrapping part of the
label, and a `{" "}` expression-spacer forcing a real space between a text
node and the link on the next source line -- plain JSX whitespace between
a text node and a tag on separate lines collapses to NOTHING, not a
space, so LeadForm.tsx needs that explicit spacer for correct rendering).
Both are stripped here so the comparison is against the flat, human-
readable text a user actually sees -- not the markup.
"""
match = re.search(r"<span>\s*(.*?)\s*</span>", tsx_source, re.DOTALL)
match = re.search(r"<span>(.*?)</span>", tsx_source, re.DOTALL)
assert match is not None, "no <span> found in LeadForm.tsx -- consent label markup changed"
return re.sub(r"\s+", " ", match.group(1)).strip()
inner = match.group(1)
inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space
inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments
inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. <Link ...>, </Link>)
return re.sub(r"\s+", " ", inner).strip()
def _extract_privacy_approval_iso_date(content_ts_source: str) -> str:
"""Pull the "DD <month genitive> YYYY" date out of PRIVACY_APPROVAL in
mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string.
PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the
order that approves the actual privacy-policy EDITION the consent
checkbox links to (/mera-public/privacy) -- it is the correct source of
truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION
(which dates the offer + refund-policy documents, a different pair).
"""
match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source)
assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts"
date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1))
assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}"
day, month_name, year = date_match.groups()
month = _RU_MONTHS_GENITIVE.get(month_name)
assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}"
return f"{year}-{month:02d}-{int(day):02d}"
def test_frontend_lead_form_exists() -> None:
@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None:
)
def test_consent_policy_version_matches_privacy_approval_date() -> None:
"""Guards the other half of the same drift class as the test above:
_CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it
claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some
unrelated date bumped by hand. A silent mismatch here would mislabel
every lead's proof-of-consent snapshot with the wrong policy edition."""
from app.api.v1.lead import _CONSENT_POLICY_VERSION
assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}"
expected_version = _extract_privacy_approval_iso_date(
_FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8")
)
assert _CONSENT_POLICY_VERSION == expected_version, (
"app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy "
"edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/"
"content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever "
"PRIVACY_APPROVAL changes (or vice versa).\n"
f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n"
f" PRIVACY_APPROVAL date: {expected_version!r}"
)
def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
"""Sanity check on the extraction helper itself, independent of the real file."""
sample = """
@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
</span>
"""
assert _extract_span_text(sample) == "Line one Line two"
def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None:
"""Sanity check: a <Link> wrapping part of the label (plus the {" "}
spacer JSX needs to force a real space before it) must collapse to plain
text, exactly like a browser renders it -- this is the shape LeadForm.tsx
actually uses today for the policy-document link."""
sample = """
<span>
Согласен(-на) на обработку персональных данных в соответствии с{" "}
<Link href={PRIVACY_PATH} target="_blank" rel="noreferrer">
Политикой обработки персональных данных
</Link>
</span>
"""
assert _extract_span_text(sample) == (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Политикой обработки персональных данных"
)
def test_extract_privacy_approval_iso_date_helper() -> None:
"""Sanity check on the RU-date extraction helper, independent of the real file."""
sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";'
assert _extract_privacy_approval_iso_date(sample) == "2026-08-13"

View file

@ -157,7 +157,10 @@ export const LEGAL_ENTITY: {
* ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой
* странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже).
*/
export const PRIVACY_PATH = "/mera-public/privacy";
// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же
// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым
// нежелательно тянуть весь этот B2C-лэндинг-модуль (см. legal-copy.ts).
export { PRIVACY_PATH } from "@/lib/legal-copy";
export const OFFER_PATH = "/mera-public/oferta";
export const REFUND_PATH = "/mera-public/refund";

View file

@ -250,8 +250,15 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
disabled={leadMutation.isPending}
/>
<span>
Согласен(-на) на обработку персональных данных в соответствии с Федеральным
законом «О персональных данных» 152-ФЗ
{/* Легаси-дерево, недостижимое с живого /trade-in/v2 (см. LeadForm.tsx
header-комментарий) текст держим в синхроне с v2-чекбоксом, чтобы
формулировки не разъезжались, если этот компонент когда-нибудь
вернётся в строй. Кликабельную ссылку здесь намеренно не добавляем:
компонент нигде не смонтирован, никакой пользователь его не видит,
и нет теста, который держал бы такую ссылку в актуальном состоянии
(в отличие от LeadForm.tsx см. test_consent_text_frontend_sync.py). */}
Согласен(-на) на обработку персональных данных в соответствии с Политикой
обработки персональных данных
</span>
</label>
{phoneInvalid && (

View file

@ -23,8 +23,10 @@
// explanation for whichever field blocked it.
import { useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import Link from "next/link";
import { tokens } from "./tokens";
import { useCreateLeadMutation } from "@/lib/trade-in-api";
import { PRIVACY_PATH } from "@/lib/legal-copy";
const {
accent,
@ -306,8 +308,14 @@ export function LeadForm({ estimateId }: LeadFormProps) {
style={{ marginTop: 2, cursor: "inherit", flex: "0 0 auto" }}
/>
<span>
Согласен(-на) на обработку персональных данных в соответствии с
Федеральным законом «О персональных данных» 152-ФЗ
Согласен(-на) на обработку персональных данных в соответствии с{" "}
{/* target="_blank" клик по ссылке не должен сбрасывать уже заполненную
форму заявки (телефон + чекбокс) в текущей вкладке. Ссылка вложена
в <label>, поэтому клик по ней НЕ переключает чекбокс (нативное
исключение для интерактивных потомков внутри <label>). */}
<Link href={PRIVACY_PATH} target="_blank" rel="noreferrer" style={{ color: accent }}>
Политикой обработки персональных данных
</Link>
</span>
</label>

View file

@ -1,25 +1,48 @@
/**
* Юридические короткие оговорки для экранов расчёта «Меры».
* Юридические константы и короткие оговорки, разделяемые между B2C-лэндингом
* (`src/app/mera-public/content.ts`) и B2B trade-in виджетами
* (`src/components/trade-in/**`, включая v2 `ResultPanel.tsx` и `LeadForm.tsx`).
*
* Источник: юр-требование владельца от 14.08.2026 (блок 4.1) прямо под
* диапазоном цены на экране результата обязана быть строка «Ориентировочный
* расчёт. Не является официальной оценкой.».
* Источник требований: юр-документ владельца от 14.08.2026 блок 4.1 (строка
* под диапазоном цены) и блок 2 (ссылка на политику ПДн рядом с чекбоксом
* согласия).
*
* Модуль НАМЕРЕННО без импортов (кроме типов) используется и из закрытого
* контура (`components/trade-in/v2/ResultPanel.tsx`,
* `components/trade-in/HeroSummary.tsx`), и потенциально из `/mera-public`,
* где действует isolation-guard
* (`scripts/check-mera-public-isolation.mjs`) любой транзитивный импорт
* ПОЧЕМУ отдельный модуль: v2-компоненты (B2B-экран, пилоты/менеджеры) не
* должны тянуть `mera-public/content.ts` целиком тот модуль набит
* B2C-лэндинг-специфичными константами (`LEGAL_ENTITY`, банковские реквизиты,
* `PUBLIC_ESTIMATE_ENABLED`), появление которых в графе импортов B2B-экрана
* было бы случайной и нежелательной связью двух поверхностей продукта.
*
* Модуль НАМЕРЕННО без импортов (кроме типов) его тянут и закрытый контур, и
* `/mera-public`, где действует isolation-guard
* (`scripts/check-mera-public-isolation.mjs`): любой транзитивный импорт
* закрытого контура (useMe / lib/api / lib/sessionId / isPathAllowed) в
* публичный бандл провалит проверку.
*
* В репозитории уже есть соседние, но НЕ идентичные дисклеймеры не путать
* и не плодить пятый/шестой литерал с той же сутью:
* В репозитории уже есть соседние, но НЕ идентичные дисклеймеры не путать и
* не плодить пятый/шестой литерал с той же сутью:
* - `src/app/mera-public/content.ts` DELIVERABLES_DISCLAIMER (135-ФЗ)
* - `src/app/mera-public/_components/SiteFooter.tsx:109`
* - `src/app/oferta/page.tsx:253`
* - `backend/app/services/trade_in_pdf.py` (версия для PDF-отчёта)
* - `src/app/mera-public/_components/SiteFooter.tsx`
* - `src/app/mera-public/oferta/page.tsx`
* - `backend/app/services/exporters/trade_in_pdf.py` (версия для PDF-отчёта)
*/
/**
* Путь страницы политики обработки персональных данных внутри Next-приложения
* (то, что понимает `next/link` с учётом `basePath=/trade-in` в проде).
* Публично та же страница доступна коротким адресом meraocenka.ru/privacy
* (см. `PUBLIC_PRIVACY_URL` в `mera-public/content.ts`), но для ссылки из
* чекбоксов согласия внутри приложения нужен именно внутренний путь.
*
* `content.ts` ре-экспортирует эту константу (не дублирует литерал)
* единственный источник истины для пути.
*/
export const PRIVACY_PATH = "/mera-public/privacy";
/**
* Строка, которая обязана стоять непосредственно под диапазоном цены на экране
* результата и в боевом `/v2` (`v2/ResultPanel.tsx`), и в превью-контуре
* (`HeroSummary.tsx`, `/trade-in/ui-preview/estimate`).
*/
export const SHORT_ESTIMATE_DISCLAIMER =
"Ориентировочный расчёт. Не является официальной оценкой.";