fix(mera): кликабельная ссылка на политику ПДн в чекбоксе согласия + синхронизация версии (блоки 2, 5.4) #2886
6 changed files with 191 additions and 37 deletions
|
|
@ -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-ФЗ"
|
||||
"Политикой обработки персональных данных"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
"Ориентировочный расчёт. Не является официальной оценкой.";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue