diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx
new file mode 100644
index 00000000..146f70ca
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx
@@ -0,0 +1,74 @@
+/**
+ * Этаж и состояние переживают ПЕРЕХОД, а не только запись в хранилище.
+ *
+ * Дефект #3321 был ровно в стыке: карточка требовала оба поля обязательными и
+ * честно клала их в черновик, а `/estimate` на монтировании звала `takeDraft`,
+ * которая стирала черновик ЦЕЛИКОМ, прочитав из него адрес, комнаты, площадь и
+ * город. Оба обязательных поля уничтожались непрочитанными — человек заполнял
+ * то, что гарантированно выбрасывалось.
+ *
+ * Поэтому здесь обе стороны перехода настоящие: форма заполняется как человеком
+ * и отправляется, затем монтируется целевая страница. Тест на одном хранилище
+ * остался бы зелёным при возврате дефекта — стирает-то целевая страница.
+ */
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { EstimateFlow } from "../_components/estimate/EstimateFlow";
+import { FreeCheckCard } from "../_components/v3/FreeCheckCard";
+import { readDraftExtras } from "../estimate-draft";
+
+beforeEach(() => {
+ window.sessionStorage.clear();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { ...window.location, assign: vi.fn() },
+ });
+});
+
+function fill(labelPart: string, value: string) {
+ const field = screen.getByLabelText(new RegExp(labelPart, "i"));
+ fireEvent.change(field, { target: { value } });
+}
+
+/** Заполняет и отправляет карточку лэндинга; возвращает выбранное состояние. */
+function submitLandingCard(): string {
+ const { unmount } = render();
+
+ fill("город", "Екатеринбург");
+ fill("адрес", "Екатеринбург, Ленина 5");
+ fill("М²", "54");
+ fill("комнат", "2");
+ fill("этаж", "7/16");
+
+ // Состояние — группа кнопок с aria-pressed. Берём подпись у самой кнопки, а
+ // не из своих догадок о наборе вариантов.
+ const conditions = screen.getByRole("group", { name: /состояние/i });
+ const chosen = conditions.querySelectorAll("button")[0];
+ // Без этой проверки пустая группа роняет тест TypeError'ом на textContent —
+ // отказом, по которому не видно, что именно разъехалось с формой.
+ expect(chosen, "в группе «Состояние» не нашлось кнопок").toBeTruthy();
+ const label = chosen.textContent ?? "";
+ fireEvent.click(chosen);
+
+ fireEvent.click(screen.getByRole("button", { name: /мо[её]му дому/i }));
+ unmount();
+ return label;
+}
+
+describe("Черновик: обязательные поля переживают переход на /estimate", () => {
+ it("этаж и состояние доступны после монтирования целевой страницы", () => {
+ const condition = submitLandingCard();
+
+ // Тот самый шаг, который раньше уничтожал оба поля.
+ render();
+
+ // Читаем так же, как будет читать платный шаг: под тот адрес, о котором
+ // спрашивают. Чужому адресу хвост не отдаётся — проверено отдельно в
+ // estimate-draft.test.ts.
+ const extras = readDraftExtras("Екатеринбург, Ленина 5");
+ expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull();
+ expect(extras?.floor).toBe("7/16");
+ expect(extras?.condition).toBe(condition);
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts
index 8449c935..5e5044a2 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
-import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft";
+import {
+ normalizeDraftRooms,
+ readDraftExtras,
+ saveDraft,
+ takeDraft,
+} from "../estimate-draft";
/**
* Черновик, который лэндинг передаёт на экран проверки.
@@ -107,6 +112,24 @@ describe("этаж переживает переход на целевую ст
expect(d?.floor).toBeUndefined();
});
+ it("черновик без этажа и состояния убирает прошлый хвост", () => {
+ // Иначе этаж от предыдущей квартиры пережил бы свой черновик и лежал бы,
+ // дожидаясь потребителя.
+ saveDraft({ address: "Ленина 5", floor: "7/16", condition: "Хорошее" });
+ takeDraft();
+ saveDraft({ address: "Малышева 51", rooms: "2" });
+ takeDraft();
+ expect(readDraftExtras("Ленина 5")).toBeNull();
+ expect(readDraftExtras("Малышева 51")).toBeNull();
+ });
+
+ it("хвост не отдаётся другому адресу", () => {
+ saveDraft({ address: "Ленина 5", floor: "7/16" });
+ takeDraft();
+ expect(readDraftExtras("Малышева 51")).toBeNull();
+ expect(readDraftExtras("Ленина 5")?.floor).toBe("7/16");
+ });
+
it("этаж не строка — поле выпадает, остальной черновик цел", () => {
window.sessionStorage.setItem(
"mera:estimate-draft",
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
index 3afc400c..11d5a423 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
@@ -201,7 +201,6 @@ export function FreeCheckCard() {
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
- // Этаж не передаём — его не использует ни проба покрытия, ни экран
// Всё, что человек ввёл обязательным полем, доезжает до следующего шага.
// Раньше этаж здесь терялся под доводом «повторно набрать дешевле, чем
// хранить лишнее» — довод был неверен: поля этажа на целевой странице нет,
diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
index b24e8567..8063187d 100644
--- a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
@@ -19,6 +19,13 @@
*/
const KEY = "mera:estimate-draft";
+/**
+ * Поля, которые целевая страница НЕ читает, но которые собраны обязательными и
+ * ждут платного шага (#2896). Отдельный ключ, потому что черновик забирается
+ * «на вынос»: без переноса `takeDraft` стирал бы вместе с адресом ровно то, что
+ * обещал донести (#3321).
+ */
+const EXTRAS_KEY = "mera:estimate-draft-extras";
export interface EstimateDraft {
address: string;
@@ -45,6 +52,16 @@ export interface EstimateDraft {
floor?: string;
}
+/**
+ * Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают.
+ * Адрес лежит здесь вместе с полями — сверку делает `readDraftExtras`.
+ */
+export interface EstimateDraftExtras {
+ address: string;
+ condition?: string;
+ floor?: string;
+}
+
/**
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
@@ -73,7 +90,7 @@ export function takeDraft(): EstimateDraft | null {
const value = parsed as Record;
if (typeof value.address !== "string") return null;
- return {
+ const draft: EstimateDraft = {
address: value.address,
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
area: typeof value.area === "string" ? value.area : undefined,
@@ -82,6 +99,63 @@ export function takeDraft(): EstimateDraft | null {
typeof value.condition === "string" ? value.condition : undefined,
floor: typeof value.floor === "string" ? value.floor : undefined,
};
+ // Состояние и этаж форма `/estimate` не спрашивает и не показывает: удали
+ // их вместе с черновиком — и человек заполнил два обязательных поля, чей
+ // единственный эффект был потрачен на удаление. Перекладываем туда, где их
+ // заберёт платный шаг; черновик как таковой по-прежнему уходит «на вынос».
+ saveDraftExtras(draft);
+ return draft;
+ } catch {
+ return null;
+ }
+}
+
+function saveDraftExtras(draft: EstimateDraft): void {
+ try {
+ // Пусто — не оставляем прошлый хвост лежать: он относится к прошлому
+ // адресу, а не к этому.
+ if (!draft.condition && !draft.floor) {
+ window.sessionStorage.removeItem(EXTRAS_KEY);
+ return;
+ }
+ const extras: EstimateDraftExtras = {
+ address: draft.address,
+ condition: draft.condition,
+ floor: draft.floor,
+ };
+ window.sessionStorage.setItem(EXTRAS_KEY, JSON.stringify(extras));
+ } catch {
+ // Не сохранился — платный шаг переспросит.
+ }
+}
+
+/**
+ * Читает, но НЕ забирает: платный шаг может открыться не с первого раза, а
+ * второе чтение того же хвоста ничего не портит. Вкладка закроется — исчезнет
+ * само.
+ *
+ * `expectedAddress` обязателен, а не «желателен»: `takeDraft` выходит раньше
+ * записи хвоста на пустом/битом черновике, поэтому хвост от ПРЕДЫДУЩЕЙ
+ * квартиры доживает до следующего захода в той же вкладке. Сверку нельзя
+ * оставлять на совесть потребителя — не тот адрес, значит ничего нет.
+ */
+export function readDraftExtras(
+ expectedAddress: string,
+): EstimateDraftExtras | null {
+ try {
+ const raw = window.sessionStorage.getItem(EXTRAS_KEY);
+ if (!raw) return null;
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const value = parsed as Record;
+ if (value.address !== expectedAddress) return null;
+
+ return {
+ address: expectedAddress,
+ condition:
+ typeof value.condition === "string" ? value.condition : undefined,
+ floor: typeof value.floor === "string" ? value.floor : undefined,
+ };
} catch {
return null;
}