diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 6ac5ba07..a0eb307a 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -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-ФЗ" + "Политикой обработки персональных данных" ) diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py index b87af5ae..b82bda69 100644 --- a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -15,8 +15,19 @@ WHAT: LeadForm.tsx (regex, no JSX parser needed -- there is exactly one 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 `` ("Политикой обработки персональных данных" 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) ... 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 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"\s*(.*?)\s*", tsx_source, re.DOTALL) + match = re.search(r"(.*?)", tsx_source, re.DOTALL) assert match is not None, "no 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. , ) + return re.sub(r"\s+", " ", inner).strip() + + +def _extract_privacy_approval_iso_date(content_ts_source: str) -> str: + """Pull the "DD 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: """ assert _extract_span_text(sample) == "Line one Line two" + + +def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None: + """Sanity check: a 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 = """ + + Согласен(-на) на обработку персональных данных в соответствии с{" "} + + Политикой обработки персональных данных + + + """ + 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" diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index a2269a15..c4ab622d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -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"; diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index fc2d8906..d4dfd991 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -250,8 +250,15 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { disabled={leadMutation.isPending} /> - Согласен(-на) на обработку персональных данных в соответствии с Федеральным - законом «О персональных данных» № 152-ФЗ + {/* Легаси-дерево, недостижимое с живого /trade-in/v2 (см. LeadForm.tsx + header-комментарий) — текст держим в синхроне с v2-чекбоксом, чтобы + формулировки не разъезжались, если этот компонент когда-нибудь + вернётся в строй. Кликабельную ссылку здесь намеренно не добавляем: + компонент нигде не смонтирован, никакой пользователь его не видит, + и нет теста, который держал бы такую ссылку в актуальном состоянии + (в отличие от LeadForm.tsx — см. test_consent_text_frontend_sync.py). */} + Согласен(-на) на обработку персональных данных в соответствии с Политикой + обработки персональных данных {phoneInvalid && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx index 3aeb5484..49c7c480 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx @@ -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" }} /> - Согласен(-на) на обработку персональных данных в соответствии с - Федеральным законом «О персональных данных» № 152-ФЗ + Согласен(-на) на обработку персональных данных в соответствии с{" "} + {/* target="_blank" — клик по ссылке не должен сбрасывать уже заполненную + форму заявки (телефон + чекбокс) в текущей вкладке. Ссылка вложена + в diff --git a/tradein-mvp/frontend/src/lib/legal-copy.ts b/tradein-mvp/frontend/src/lib/legal-copy.ts index 0ba3680e..dbfbc2ac 100644 --- a/tradein-mvp/frontend/src/lib/legal-copy.ts +++ b/tradein-mvp/frontend/src/lib/legal-copy.ts @@ -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 = "Ориентировочный расчёт. Не является официальной оценкой.";