From d694e735cdb8cb4a92f9c439eb75d8fcef5cf6d3 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 12:51:35 +0500 Subject: [PATCH 01/13] =?UTF-8?q?fix(site-finder):=20per-row=20SAVEPOINT?= =?UTF-8?q?=20=D0=B2=20noise=5Floader=20(#1231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop UPSERT в sync_noise_sources_to_db без begin_nested: один замкнутый 3-точечный natural=water way [A,B,A] даёт POLYGON((A,B,A)), PostGIS отвергает (< 4 точек в ring) → outer tx rollback + raise → весь weekly noise/water/utility sync падает, тот же way отравляет каждый прогон. - Оборачиваем каждый UPSERT в `with db.begin_nested():` + per-row try/except → logger.warning + skipped++ (канон pzz_loader.py:111). - В _way_to_polygon_wkt проверяем итоговое кольцо ≥ 4 точек (fail-safe). - Outer except: добавлен logger.exception для видимости. Closes #1231 --- .../app/services/site_finder/noise_loader.py | 75 ++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index cc2f4f6e..30802325 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -126,6 +126,11 @@ def _way_to_polygon_wkt(nodes: list[dict]) -> str | None: Используется для natural=water (озёра, пруды). Если кольцо не замкнуто автоматически — добавляем первую точку в конец. + + PostGIS отвергает linear ring < 4 точек. Замкнутый way из 3 точек + [A, B, A] (points[0]==points[-1]) даёт POLYGON((A,B,A)) — "geometry + requires more points". Возвращаем None, чтобы caller сделал fallback + на LineString или пропустил элемент. """ points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n] if len(points) < 3: @@ -133,6 +138,9 @@ def _way_to_polygon_wkt(nodes: list[dict]) -> str | None: # Замкнуть кольцо если нужно if points[0] != points[-1]: points.append(points[0]) + # Итоговое кольцо должно содержать ≥4 точек (PostGIS требование). + if len(points) < 4: + return None return f"POLYGON(({', '.join(points)}))" @@ -203,35 +211,50 @@ def sync_noise_sources_to_db() -> dict[str, int]: **geom_params, } - result = db.execute( - text(f""" - INSERT INTO osm_noise_sources_ekb - (osm_type, osm_id, source_type, road_class, name, geom, tags, fetched_at) - VALUES ( - :osm_type, :osm_id, :source_type, :road_class, :name, - {geom_sql}, - CAST(:tags AS jsonb), NOW() - ) - ON CONFLICT (osm_type, osm_id) DO UPDATE - SET source_type = EXCLUDED.source_type, - road_class = EXCLUDED.road_class, - name = EXCLUDED.name, - geom = EXCLUDED.geom, - tags = EXCLUDED.tags, - fetched_at = NOW() - RETURNING (xmax = 0) AS is_insert - """), - params, - ).scalar() - - if result: - inserted += 1 - else: - updated += 1 + try: + with db.begin_nested(): # SAVEPOINT — откат только этой записи + result = db.execute( + text(f""" + INSERT INTO osm_noise_sources_ekb + (osm_type, osm_id, source_type, road_class, name, geom, tags, + fetched_at) + VALUES ( + :osm_type, :osm_id, :source_type, :road_class, :name, + {geom_sql}, + CAST(:tags AS jsonb), NOW() + ) + ON CONFLICT (osm_type, osm_id) DO UPDATE + SET source_type = EXCLUDED.source_type, + road_class = EXCLUDED.road_class, + name = EXCLUDED.name, + geom = EXCLUDED.geom, + tags = EXCLUDED.tags, + fetched_at = NOW() + RETURNING (xmax = 0) AS is_insert + """), + params, + ).scalar() + if result: + inserted += 1 + else: + updated += 1 + except Exception as e: + # Дефектный way (например, 3-точечное замкнутое кольцо POLYGON((A,B,A)) + # отвергается PostGIS) не должен валить весь weekly-sync. SAVEPOINT + # откатывает только эту строку, продолжаем с остальными. + logger.warning( + "noise_sync insert failed for %s/%s (source_type=%s): %s", + osm_type, + osm_id, + source_type, + e, + ) + skipped += 1 db.commit() - except Exception: + except Exception as e: db.rollback() + logger.exception("noise_sync: unexpected error, outer tx rolled back: %s", e) raise finally: db.close() -- 2.45.3 From 780dd5640cd171028c4e8d445433679aa2dfb7c1 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 13:09:22 +0500 Subject: [PATCH 02/13] fix(analytics): _district_market_saturation latest-snapshot filter (#1221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domrf_kn_objects/sales_agg хранят ~3 snapshot'а на obj_id (weekly UPSERT, retention=none). JOIN без latest-фильтра считал каждый ЖК N раз: sold_median тянулся старыми perc → sat_factor занижен, гейт n>=5 проходил уже при 2 ЖК (n=6), sat_n завышен ~×N в scope. Сданные ЖК просачивались через старые снапшоты со статусом 'Строящиеся'. Patch: AND a.snapshot_date = (SELECT MAX(snapshot_date) FROM ...) для обеих таблиц (зеркало #1210/#1212). COUNT(*) → COUNT(DISTINCT a.obj_id) defensive. 29 recommend_mix + 8 saturation тестов зелёные. Closes #1221 --- backend/app/services/analytics_queries.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 2fcb98d8..2e0c35c9 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1414,12 +1414,21 @@ def _district_market_saturation(db: Session, *, district_name: str) -> tuple[flo Возвращает (median_pct, n_objects). None если <5 ЖК с perc. """ + # #1221: domrf_kn_objects/domrf_kn_sales_agg хранят ~3 snapshot'а на obj_id + # (UNIQUE(obj_id,[type,]snapshot_date), weekly UPSERT, ретенции нет). Без + # latest-фильтра один ЖК входил в медиану N раз → sold_median тянется старыми + # perc (sold% растёт во времени → sat_factor занижен), гейт n>=5 проходил уже + # при 2 ЖК, sat_n завышен ~×N. Фильтруем оба JOIN-партнёра MAX(snapshot_date) + # (зеркало #1210/#1212, ср. строки 1200/1208 и latest_obj-CTE стр. ~2780 + # «иначе comparables дублируются»). COUNT(DISTINCT a.obj_id) — defensive: + # после latest-snapshot пары (obj_id, type='apartments') уникальны, но явный + # DISTINCT исключает регрессию, если кто-то ослабит UNIQUE-constraint. row = ( db.execute( text( """ SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median, - COUNT(*) AS n + COUNT(DISTINCT a.obj_id) AS n FROM domrf_kn_sales_agg a JOIN domrf_kn_objects o ON o.obj_id = a.obj_id @@ -1428,6 +1437,12 @@ def _district_market_saturation(db: Session, *, district_name: str) -> tuple[flo AND a.perc IS NOT NULL AND o.district_name = :dn AND o.site_status = 'Строящиеся' + AND a.snapshot_date = ( + SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg + ) + AND o.snapshot_date = ( + SELECT MAX(snapshot_date) FROM domrf_kn_objects + ) """ ), {"dn": district_name}, -- 2.45.3 From 703d3905b8b69b485a617d261a41f10989f9e6ef Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 13:09:42 +0500 Subject: [PATCH 03/13] fix(site-finder): normalize supply room_bucket vocabulary to velocity side (#1229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit best_layouts._SUPPLY_BATCH_SQL эмитил {studio,euro-1,euro-2,1,2,3,4+}, а _INLINE_VELOCITY_SQL читает {студия,1,2,3,4+} из objective_corpus_room_month (prod check: 'euro-*' rows отсутствуют). Эффект: rooms=2 + area<50 уходили в euro-1/euro-2 supply-стороной → выпадали из знаменателя bucket '2' → sold_pct_of_supply двушек завышен, is_oversold ложно True. (rb='euro-*') dead lookups в supply_map. Patch: убраны euro-* WHEN в supply CASE. SF-08 euro-биннинг отложен до момента когда velocity-сторона начнёт его отдавать. +2 regression теста (bucket match, string guard). 35 best_layouts тестов зелёные. Closes #1229 --- .../app/services/site_finder/best_layouts.py | 13 +++-- .../services/site_finder/test_best_layouts.py | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 14faf117..86226793 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -148,17 +148,20 @@ _INLINE_VELOCITY_SQL = text(""" # ── SQL: supply по (room_bucket, area_bin) за последний снимок ─────────────── # Один батч-запрос вместо N — возвращает map (rb, ab) → count. # room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py. +# Fix #1229: bucket'ы нормализованы под velocity-вокабуляр objective_corpus_room_month +# = {studio, 1, 2, 3, 4+}. Раньше supply отдельно отдавал euro-1/euro-2 для rooms=2 +# (area<35 / area<50) — эти ключи никогда не матчились с velocity (Объектив +# euro-форматов не выделяет, см. _INLINE_VELOCITY_SQL passthrough room_bucket). +# Последствия: 1) supply '2' искусственно занижен (rooms=2 + area<50 уходили в +# euro-*) → sold_pct/is_oversold двушек завышены; 2) euro-* строки supply +# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его +# начнёт отдавать velocity-сторона. _SUPPLY_BATCH_SQL = text(""" SELECT CASE WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio' WHEN f.rooms = 0 THEN 'studio' - -- Fix SF-08: euro-форматы — DOM.РФ маркирует малогабаритные квартиры как 2-комн. - -- rooms=2 + area<35 → euro-1 (студия с отдельной кухней ~26м²) - -- rooms=2 + area<50 → euro-2 (~35-50м², евро-двушка) - WHEN f.rooms = 2 AND f.total_area < 35 THEN 'euro-1' - WHEN f.rooms = 2 AND f.total_area < 50 THEN 'euro-2' WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text WHEN f.rooms >= 4 THEN '4+' ELSE '1' diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 0720feaa..c88a6dfc 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -342,6 +342,57 @@ def test_total_sold_in_window_matches_deals_window() -> None: assert resp.top_layouts[0].total_sold_in_window == int(deals) +# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ──────────────── + + +def test_supply_velocity_buckets_match_for_2_rooms() -> None: + """Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади. + + Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 → эти + строки выпадали из знаменателя bucket '2' → sold_pct/is_oversold двушек + были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct + рассчитывается от полного supply. + + Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в + один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply → 40% sold. + """ + deals = 20.0 + id_rows = [_obj_id_row(7)] + vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])] + supply_rows = [_supply_row("2", "40-60", 50)] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + supply_rows=supply_rows, + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + assert len(resp.top_layouts) == 1 + row = resp.top_layouts[0] + assert row.room_bucket == "2" + assert row.supply_units_in_radius == 50 + # 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше + # → sold_pct > 40 или is_oversold=True). + assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3) + assert row.is_oversold is False + + +def test_supply_does_not_emit_euro_buckets() -> None: + """Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'. + + Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply + без согласования с velocity-стороной (objective_corpus_room_month отдаёт + {studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет. + """ + from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL + + sql_text = str(_SUPPLY_BATCH_SQL.text) + assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229" + assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229" + + # ── Тесты _cap_and_redistribute (Fix SF-09 review) ─────────────────────────── -- 2.45.3 From fbafb1bf68141b0f70734861bf1aff63875d3c39 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 13:12:25 +0500 Subject: [PATCH 04/13] =?UTF-8?q?fix(landing):=20mirror=20Pydantic=20schem?= =?UTF-8?q?a=20=D0=B2=20PilotRequestModal=20validation=20(#1240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend PilotRequestInput требует name min_length=2 и др., но modal проверял только !form.name.trim(). 1-char name ('Я') проходил, backend возвращал 422, apiFetch клал raw FastAPI JSON в Error.message, modal рендерил его буквально для public lead form. Patch: PILOT_LIMITS + PILOT_EMAIL_RE константы (синхр с backend), per-field onBlur+live валидация с RU-сообщениями, minLength/maxLength + aria-invalid/aria-describedby, submit disabled при ошибках, formatSubmitError → 4xx/5xx → human text. 26 vitest кейсов. Closes #1240 --- .../components/landing/PilotRequestModal.tsx | 269 +++++++++++++++--- .../__tests__/PilotRequestModal.test.tsx | 254 +++++++++++++++++ 2 files changed, 478 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/landing/__tests__/PilotRequestModal.test.tsx diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx index d8710d64..aab73461 100644 --- a/frontend/src/components/landing/PilotRequestModal.tsx +++ b/frontend/src/components/landing/PilotRequestModal.tsx @@ -25,6 +25,25 @@ interface FormState { message: string; } +type FieldName = keyof FormState; +type FieldErrors = Partial>; + +// ── Schema mirror ────────────────────────────────────────────────────────── +// Зеркалирует backend Pydantic-схему `PilotRequestInput` +// (`backend/app/api/v1/pilot.py:25-34`). При изменении на бэкенде — +// синхронизировать здесь, иначе лид получит 422 и сырой JSON в модалке. +export const PILOT_LIMITS = { + name: { min: 2, max: 200 }, + phone: { max: 50 }, + email: { max: 200 }, + company: { max: 200 }, + message: { max: 2000 }, +} as const; + +// Тот же паттерн, что и в Pydantic Field(pattern=...) на бэкенде +// (`backend/app/api/v1/pilot.py:31`). +export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + // ── Helpers ──────────────────────────────────────────────────────────────── /** Derives a human-readable tracking ID from the UUID string returned by backend. @@ -34,6 +53,85 @@ function deriveTrackingId(id: string): string { return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase(); } +/** + * Валидирует одно поле против ограничений схемы. Возвращает текст ошибки + * (понятный пользователю) или пустую строку, если поле валидно. + * + * Экспортится для unit-тестов и потенциального переиспользования. + */ +export function validatePilotField(name: FieldName, value: string): string { + const trimmed = value.trim(); + switch (name) { + case "name": + if (!trimmed) return "Укажите имя"; + if (trimmed.length < PILOT_LIMITS.name.min) + return `Минимум ${PILOT_LIMITS.name.min} символа`; + if (trimmed.length > PILOT_LIMITS.name.max) + return `Максимум ${PILOT_LIMITS.name.max} символов`; + return ""; + case "phone": + if (trimmed.length > PILOT_LIMITS.phone.max) + return `Максимум ${PILOT_LIMITS.phone.max} символов`; + return ""; + case "email": + if (!trimmed) return ""; + if (trimmed.length > PILOT_LIMITS.email.max) + return `Максимум ${PILOT_LIMITS.email.max} символов`; + if (!PILOT_EMAIL_RE.test(trimmed)) return "Некорректный email"; + return ""; + case "company": + if (trimmed.length > PILOT_LIMITS.company.max) + return `Максимум ${PILOT_LIMITS.company.max} символов`; + return ""; + case "message": + if (trimmed.length > PILOT_LIMITS.message.max) + return `Максимум ${PILOT_LIMITS.message.max} символов`; + return ""; + default: + return ""; + } +} + +/** Валидирует все поля разом. Пустой объект — форма валидна. */ +export function validatePilotForm(form: FormState): FieldErrors { + const errors: FieldErrors = {}; + (Object.keys(form) as FieldName[]).forEach((key) => { + const err = validatePilotField(key, form[key]); + if (err) errors[key] = err; + }); + return errors; +} + +/** + * Дружелюбное сообщение об ошибке отправки. + * apiFetch (`lib/api.ts:39`) кладёт сырой response.text() в Error.message + * как «API error 422: {"detail":[...]}». На публичной лид-форме сырой JSON + * показывать нельзя — поэтому мапим коды в человеческий текст. На 422 это + * fallback при рассинхроне фронт/бэк валидации; клиент-сайд должен ловить + * это до отправки. + */ +export function formatSubmitError(err: unknown): string { + const fallback = "Не удалось отправить заявку. Попробуйте позже."; + if (!(err instanceof Error)) return fallback; + const match = err.message.match(/^API error (\d{3}):\s*(.*)$/s); + if (!match) return fallback; + const status = Number(match[1]); + const body = match[2]; + if (status === 422) { + return "Проверьте корректность заполнения полей."; + } + if (status >= 500) { + return "Сервер недоступен. Попробуйте позже."; + } + try { + const parsed = JSON.parse(body) as { detail?: unknown }; + if (typeof parsed.detail === "string") return parsed.detail; + } catch { + // not JSON — fall through to fallback + } + return fallback; +} + // ── Component ────────────────────────────────────────────────────────────── export function PilotRequestModal({ open, onClose }: Props) { @@ -44,28 +142,48 @@ export function PilotRequestModal({ open, onClose }: Props) { company: "", message: "", }); - const [emailError, setEmailError] = useState(""); + const [errors, setErrors] = useState({}); const [trackingId, setTrackingId] = useState(null); const mutation = useSubmitPilotRequest(); - const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - function handleChange( e: React.ChangeEvent, ) { const { name, value } = e.target; - setForm((prev) => ({ ...prev, [name]: value })); - if (name === "email") { - setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : ""); - } + const fieldName = name as FieldName; + setForm((prev) => ({ ...prev, [fieldName]: value })); + // Live re-validate только для полей где ошибка уже показана (клиент сразу + // видит, что фикс «помог»), плюс email — исторически на онлайн-проверке. + setErrors((prev) => { + if (fieldName !== "email" && !prev[fieldName]) return prev; + const err = validatePilotField(fieldName, value); + const next: FieldErrors = { ...prev }; + if (err) next[fieldName] = err; + else delete next[fieldName]; + return next; + }); + } + + function handleBlur( + e: React.FocusEvent, + ) { + const { name, value } = e.target; + const fieldName = name as FieldName; + const err = validatePilotField(fieldName, value); + setErrors((prev) => { + const next: FieldErrors = { ...prev }; + if (err) next[fieldName] = err; + else delete next[fieldName]; + return next; + }); } function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!form.name.trim()) return; - if (form.email && !EMAIL_RE.test(form.email)) { - setEmailError("Некорректный email"); + const validation = validatePilotForm(form); + if (Object.keys(validation).length > 0) { + setErrors(validation); return; } @@ -76,7 +194,7 @@ export function PilotRequestModal({ open, onClose }: Props) { ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.company.trim() ? { company: form.company.trim() } : {}), ...(form.message.trim() - ? { message: form.message.trim().slice(0, 2000) } + ? { message: form.message.trim().slice(0, PILOT_LIMITS.message.max) } : {}), }; @@ -89,12 +207,15 @@ export function PilotRequestModal({ open, onClose }: Props) { function handleClose() { setForm({ name: "", phone: "", email: "", company: "", message: "" }); - setEmailError(""); + setErrors({}); setTrackingId(null); mutation.reset(); onClose(); } + const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0; + const submitDisabled = mutation.isPending || hasBlockingErrors; + return ( {/* Header */} @@ -191,10 +312,25 @@ export function PilotRequestModal({ open, onClose }: Props) { name="name" value={form.name} onChange={handleChange} + onBlur={handleBlur} required + minLength={PILOT_LIMITS.name.min} + maxLength={PILOT_LIMITS.name.max} placeholder="Алексей Кириллов" - style={inputStyle} + aria-invalid={errors.name ? "true" : "false"} + aria-describedby={errors.name ? "pilot-name-error" : undefined} + style={{ + ...inputStyle, + borderColor: errors.name + ? "var(--danger)" + : "var(--border-strong)", + }} /> + {errors.name ? ( + + {errors.name} + + ) : null} {/* Company */} @@ -207,9 +343,25 @@ export function PilotRequestModal({ open, onClose }: Props) { name="company" value={form.company} onChange={handleChange} + onBlur={handleBlur} + maxLength={PILOT_LIMITS.company.max} placeholder="PRINZIP" - style={inputStyle} + aria-invalid={errors.company ? "true" : "false"} + aria-describedby={ + errors.company ? "pilot-company-error" : undefined + } + style={{ + ...inputStyle, + borderColor: errors.company + ? "var(--danger)" + : "var(--border-strong)", + }} /> + {errors.company ? ( + + {errors.company} + + ) : null} {/* Phone */} @@ -223,9 +375,25 @@ export function PilotRequestModal({ open, onClose }: Props) { type="tel" value={form.phone} onChange={handleChange} + onBlur={handleBlur} + maxLength={PILOT_LIMITS.phone.max} placeholder="+7 900 000-00-00" - style={inputStyle} + aria-invalid={errors.phone ? "true" : "false"} + aria-describedby={ + errors.phone ? "pilot-phone-error" : undefined + } + style={{ + ...inputStyle, + borderColor: errors.phone + ? "var(--danger)" + : "var(--border-strong)", + }} /> + {errors.phone ? ( + + {errors.phone} + + ) : null} {/* Email */} @@ -239,24 +407,23 @@ export function PilotRequestModal({ open, onClose }: Props) { type="email" value={form.email} onChange={handleChange} + onBlur={handleBlur} + maxLength={PILOT_LIMITS.email.max} placeholder="alex@prinzip.ru" + aria-invalid={errors.email ? "true" : "false"} + aria-describedby={ + errors.email ? "pilot-email-error" : undefined + } style={{ ...inputStyle, - borderColor: emailError + borderColor: errors.email ? "var(--danger)" : "var(--border-strong)", }} /> - {emailError ? ( - - {emailError} + {errors.email ? ( + + {errors.email} ) : null} @@ -271,14 +438,22 @@ export function PilotRequestModal({ open, onClose }: Props) { name="message" value={form.message} onChange={handleChange} + onBlur={handleBlur} placeholder="Расскажите о вашем проекте или вопросе..." rows={4} - maxLength={2000} + maxLength={PILOT_LIMITS.message.max} + aria-invalid={errors.message ? "true" : "false"} + aria-describedby={ + errors.message ? "pilot-message-error" : undefined + } style={{ ...inputStyle, resize: "vertical", minHeight: 96, fontFamily: "inherit", + borderColor: errors.message + ? "var(--danger)" + : "var(--border-strong)", }} /> - {form.message.length} / 2000 + {form.message.length} / {PILOT_LIMITS.message.max} + {errors.message ? ( + + {errors.message} + + ) : null} {/* Error */} {mutation.isError ? (
- {mutation.error instanceof Error - ? mutation.error.message - : "Ошибка отправки. Попробуйте позже."} + {formatSubmitError(mutation.error)}
) : null} {/* Submit */}