fix(tradein/lead): кликабельная ссылка на политику ПДн в чекбоксе согласия
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ
политики обработки ПДн, а не упоминание закона. Чекбокс в LeadForm.tsx
(v2, живой /trade-in/v2) теперь линкует "Политикой обработки персональных
данных" на /mera-public/privacy (target=_blank, чтобы не терять заполненную
форму). Путь вынесен в новый src/lib/legal-copy.ts (модуль без импортов) —
content.ts ре-экспортирует оттуда, чтобы B2B-виджет не тянул B2C-лэндинг-модуль
целиком.
_CONSENT_TEXT_SNAPSHOT/_CONSENT_POLICY_VERSION в lead.py обновлены под новый
плоский текст и дату утверждения политики (PRIVACY_APPROVAL: 2026-08-13).
test_consent_text_frontend_sync.py: экстрактор теперь снимает JSX-теги/{" "}
спейсеры перед сравнением (иначе сломался бы на разметке ссылки) + новый тест
держит _CONSENT_POLICY_VERSION в синхроне с PRIVACY_APPROVAL из content.ts,
чтобы версия не расходилась молча с редакцией документа.
Легаси-дубль в HeroTransparency.tsx (недостижим с живого роута) — текст
приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет
теста, который держал бы там ссылку в актуальном состоянии.
This commit is contained in:
parent
b0776cd01d
commit
74e7969ef9
6 changed files with 180 additions and 23 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>
|
||||
|
||||
|
|
|
|||
26
tradein-mvp/frontend/src/lib/legal-copy.ts
Normal file
26
tradein-mvp/frontend/src/lib/legal-copy.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Юридические константы, разделяемые между B2C-лэндингом
|
||||
* (`src/app/mera-public/content.ts`) и B2B trade-in виджетами
|
||||
* (`src/components/trade-in/**`, включая v2 `LeadForm.tsx`).
|
||||
*
|
||||
* ПОЧЕМУ отдельный модуль: v2-компонент результата оценки (B2B-экран,
|
||||
* пилоты/менеджеры) не должен тянуть `mera-public/content.ts` целиком —
|
||||
* тот модуль набит B2C-лэндинг-специфичными константами (`LEGAL_ENTITY`,
|
||||
* `PUBLIC_ESTIMATE_ENABLED`, банковские реквизиты и т.д.), появление
|
||||
* которых в графе импортов B2B-экрана было бы случайной и нежелательной
|
||||
* связью двух разных поверхностей продукта. Вместо этого путь к странице
|
||||
* политики ПДн живёт здесь — в модуле БЕЗ импортов, который могут
|
||||
* безопасно использовать обе стороны.
|
||||
*
|
||||
* `content.ts` ре-экспортирует `PRIVACY_PATH` отсюда (не дублирует
|
||||
* литерал) — единственный источник истины для пути.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Путь страницы политики обработки персональных данных внутри Next-приложения
|
||||
* (то, что понимает `next/link` с учётом `basePath=/trade-in` в проде).
|
||||
* Публично та же страница доступна коротким адресом meraocenka.ru/privacy
|
||||
* (см. `PUBLIC_PRIVACY_URL` в `mera-public/content.ts`), но для ссылки из
|
||||
* чекбоксов согласия внутри приложения нужен именно внутренний путь.
|
||||
*/
|
||||
export const PRIVACY_PATH = "/mera-public/privacy";
|
||||
Loading…
Add table
Reference in a new issue