feat(tradein): аккаунт praktika + лимит 15 оценок/мес, чистка PRINZIP в UI #635

Merged
lekss361 merged 1 commit from feat/tradein-quota-praktika into main 2026-05-28 22:04:59 +00:00
12 changed files with 753 additions and 38 deletions
Showing only changes of commit 496f18e16d - Show all commits

View file

@ -13,7 +13,7 @@
# any pattern in `deny`. `deny` overrides `paths` (deny-by-default within
# match scope).
#
# Username list MUST match exactly the 12 entries in caddy/users.caddy.snippet
# Username list MUST match exactly the entries in caddy/users.caddy.snippet
# — Caddy basic_auth sets X-Authenticated-User from {http.auth.user.id}, and
# unknown users hit /me with 403 ("user not in roles config").
#
@ -53,5 +53,6 @@ users:
user8: pilot
user9: pilot
user10: pilot
praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29
admintest: admin # temp QA 2026-05-26
pilottest: pilot # temp QA 2026-05-26

View file

@ -19,6 +19,7 @@ basic_auth bcrypt "GenDesign Pilot" {
user8 JDJhJDE0JDVxNEE4RVRVWi4yUkY2OG9KR2Z4MU8xYWVDYTdkTDNVckJGZHBZM3c5VjlqS3ZyNEFtcWVl # pilot slot (regenerated 2026-05-26)
user9 JDJhJDE0JGwuaDl1U2JkT1dOWDguVXhJMFozZk9Ec09NVmxyM2J2VVdxdlFzdktXWVNkZDVBMVBuY3Z5 # pilot slot (regenerated 2026-05-26)
user10 JDJhJDE0JEN4QlV2Z3Q3c2VqMU92OFV4YUo3cWVUUXJkRGExVU9RL3BYNzUweXhXZDdhdGh1MlFwcnN5 # pilot slot (regenerated 2026-05-26)
praktika JDJhJDE0JDV4UzZiWmNPRXZIOWNERkpQSURjbXU0bjJJUnBCNHZDaFZYTkw2d0YwT3d0ekQ0U1dPVjdL # Практика (pilot) 2026-05-29
kopylov JDJhJDE0JExUald3alVMbS5RdVV0TTRTcGk0cE9YeHNXS2E1QU5rTFp2TnFFQzdmWWNFV3djRVAzOUdH # Копылов (2026-05-23)
admintest JDJhJDE0JHB6TDV6ZWZFTmZuTTd0S2I0Uy8vZ3VlL2w5bEpKbjEwTGVYVTd2bkRhNW5qbGlYTlhvVkxT # QA test admin role 2026-05-26
pilottest JDJhJDE0JFQwTW9UV1I0WHUwek5qeFR5SWxTQy4veFFFUzlvL1VoMHFycnJDTkU5czRoVXdwNXRKM2ph # QA test pilot role 2026-05-26

View file

@ -10,7 +10,7 @@ from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -26,6 +26,7 @@ from app.schemas.trade_in import (
PhotoMeta,
PlacementHistoryEntry,
PriceHistoryYearPoint,
QuotaStatus,
RecentSoldEntry,
SalesListingPair,
SalesVsListingsResponse,
@ -34,6 +35,7 @@ from app.schemas.trade_in import (
StreetDealsResponse,
TradeInEstimateInput,
)
from app.services import account_quota
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
@ -46,6 +48,7 @@ router = APIRouter()
async def estimate(
payload: TradeInEstimateInput,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> AggregatedEstimate:
"""Реальная оценка через SQL aggregation поверх listings + deals.
@ -53,9 +56,28 @@ async def estimate(
2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
3. Tukey IQR outlier filter
4. Median + Q1 + Q3 + confidence с explanation
Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov).
"""
account_quota.check_and_raise(db, x_authenticated_user)
from app.services.estimator import estimate_quality
return await estimate_quality(payload, db)
result = await estimate_quality(payload, db)
account_quota.increment(db, x_authenticated_user)
return result
@router.get("/quota", response_model=QuotaStatus)
def get_quota(
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> QuotaStatus:
"""Статус квоты оценок для текущего аккаунта.
Возвращает limit / used / remaining / unlimited для X-Authenticated-User.
Без заголовка (dev-режим без Caddy) unlimited True, used 0.
"""
status = account_quota.get_status(db, x_authenticated_user)
return QuotaStatus(**status)
@router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate)

View file

@ -382,3 +382,15 @@ class SalesVsListingsResponse(BaseModel):
linkage_rate_pct: float # deals_with_listings / total_deals * 100
median_discount_pct: float | None # медиана по парам с listing
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
# ── Account quota (#quota) ──────────────────────────────────────────────────
class QuotaStatus(BaseModel):
"""Статус квоты оценок для текущего аккаунта (GET /api/v1/trade-in/quota)."""
limit: int # MONTHLY_LIMIT = 15
used: int # использовано в текущем месяце
remaining: int # max(0, limit - used)
unlimited: bool # True для admin / kopylov / без заголовка

View file

@ -0,0 +1,157 @@
"""Сервис квоты оценок trade-in — 15 успешных оценок в месяц на аккаунт.
Правила:
- Лимит = 15 успешных оценок за календарный месяц (UTC, период 'YYYY-MM').
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited,
лимит не применяется (fail-open).
- При исчерпании лимита поднимается HTTPException(429).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.auth import get_role
logger = logging.getLogger(__name__)
MONTHLY_LIMIT = 15
LIMIT_EXHAUSTED_MESSAGE = (
"Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым."
)
def current_period() -> str:
"""Возвращает текущий период в формате 'YYYY-MM' (UTC)."""
return datetime.now(UTC).strftime("%Y-%m")
def is_unlimited(username: str) -> bool:
"""True если пользователь не ограничен квотой.
Unlimited: роль admin ИЛИ username == 'kopylov'.
KeyError (неизвестный пользователь) трактуется как limited (False).
"""
if username == "kopylov":
return True
try:
role = get_role(username)
return role == "admin"
except KeyError:
return False
def get_status(db: Session, username: str | None) -> dict:
"""Возвращает статус квоты для пользователя.
Если username is None unlimited True, used 0, remaining 15.
Если unlimited used = фактический или 0, remaining = limit.
"""
if username is None:
return {
"limit": MONTHLY_LIMIT,
"used": 0,
"remaining": MONTHLY_LIMIT,
"unlimited": True,
}
unlimited = is_unlimited(username)
period = current_period()
row = db.execute(
text(
"""
SELECT used FROM account_estimate_usage
WHERE username = :u AND period_month = :p
"""
),
{"u": username, "p": period},
).fetchone()
used = row.used if row is not None else 0
if unlimited:
return {
"limit": MONTHLY_LIMIT,
"used": used,
"remaining": MONTHLY_LIMIT,
"unlimited": True,
}
remaining = max(0, MONTHLY_LIMIT - used)
return {
"limit": MONTHLY_LIMIT,
"used": used,
"remaining": remaining,
"unlimited": False,
}
def check_and_raise(db: Session, username: str | None) -> None:
"""Проверяет лимит квоты и поднимает 429 если исчерпан.
Если username is None или пользователь unlimited no-op.
"""
if username is None:
return
if is_unlimited(username):
return
period = current_period()
row = db.execute(
text(
"""
SELECT used FROM account_estimate_usage
WHERE username = :u AND period_month = :p
"""
),
{"u": username, "p": period},
).fetchone()
used = row.used if row is not None else 0
if used >= MONTHLY_LIMIT:
logger.warning(
"quota exhausted: username=%r period=%s used=%d limit=%d",
username, period, used, MONTHLY_LIMIT,
)
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
def increment(db: Session, username: str | None) -> None:
"""Инкрементирует счётчик успешных оценок для пользователя.
Если username is None или пользователь unlimited no-op.
UPSERT: INSERT ... ON CONFLICT DO UPDATE SET used = used + 1.
"""
if username is None:
return
if is_unlimited(username):
return
period = current_period()
db.execute(
text(
"""
INSERT INTO account_estimate_usage (username, period_month, used, updated_at)
VALUES (:u, :p, 1, NOW())
ON CONFLICT (username, period_month)
DO UPDATE SET
used = account_estimate_usage.used + 1,
updated_at = NOW()
"""
),
{"u": username, "p": period},
)
db.commit()
logger.debug(
"quota incremented: username=%r period=%s", username, period
)

View file

@ -0,0 +1,36 @@
-- Migration 076: account_estimate_usage — квота оценок на аккаунт (#quota)
--
-- Хранит счётчик успешных оценок per (username, period_month).
-- period_month — строка 'YYYY-MM' (UTC). Лимит проверяется/инкрементится
-- в app.services.account_quota.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS + существующие COMMENT переписываются
-- безопасно (COMMENT ON TABLE/COLUMN — DML, не DDL, не падает при повторе).
BEGIN;
CREATE TABLE IF NOT EXISTS account_estimate_usage (
username text NOT NULL,
period_month text NOT NULL, -- 'YYYY-MM' (UTC)
used integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT NOW(),
PRIMARY KEY (username, period_month)
);
COMMENT ON TABLE account_estimate_usage IS
'Счётчик успешных оценок trade-in per (username, calendar month UTC). '
'Используется для применения лимита 15 оценок/месяц на pilot-аккаунтах.';
COMMENT ON COLUMN account_estimate_usage.username IS
'Имя пользователя из заголовка X-Authenticated-User (Caddy basic_auth).';
COMMENT ON COLUMN account_estimate_usage.period_month IS
'Календарный месяц UTC в формате YYYY-MM, напр. ''2026-05''.';
COMMENT ON COLUMN account_estimate_usage.used IS
'Количество успешных оценок в данном месяце.';
COMMENT ON COLUMN account_estimate_usage.updated_at IS
'Время последнего инкремента (UTC).';
COMMIT;

View file

@ -0,0 +1,390 @@
"""Tests for app.services.account_quota — monthly estimate quota enforcement.
Coverage:
(a) admin и kopylov unlimited не блокируются, increment является no-op
(b) обычный pilot-юзер блокируется на 16-м запросе (429 + нужный detail)
(c) increment растит used счётчик
(d) get_status корректен для different сценариев
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
DB мокируется через MagicMock реальная БД не требуется.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
# psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint requires GTK — not present in CI/Windows
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.services.account_quota import ( # noqa: E402
LIMIT_EXHAUSTED_MESSAGE,
MONTHLY_LIMIT,
check_and_raise,
get_status,
increment,
is_unlimited,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _db_with_used(used: int) -> MagicMock:
"""DB session mock whose execute().fetchone() returns row with .used = N."""
row = MagicMock()
row.used = used
execute_result = MagicMock()
execute_result.fetchone.return_value = row
db = MagicMock()
db.execute.return_value = execute_result
return db
def _db_no_row() -> MagicMock:
"""DB session mock where no row exists yet (first estimate of the month)."""
execute_result = MagicMock()
execute_result.fetchone.return_value = None
db = MagicMock()
db.execute.return_value = execute_result
return db
# ---------------------------------------------------------------------------
# (a) admin and kopylov are unlimited
# ---------------------------------------------------------------------------
def test_is_unlimited_admin() -> None:
assert is_unlimited("admin") is True
def test_is_unlimited_kopylov() -> None:
assert is_unlimited("kopylov") is True
def test_is_unlimited_pilot_user1() -> None:
assert is_unlimited("user1") is False
def test_is_unlimited_unknown_user() -> None:
"""Неизвестный пользователь → False (KeyError трактуется как limited)."""
assert is_unlimited("ghost_unknown_xyz") is False
def test_check_and_raise_admin_not_blocked() -> None:
"""admin с used=15 не получает 429."""
db = _db_with_used(MONTHLY_LIMIT)
# Should not raise
check_and_raise(db, "admin")
def test_check_and_raise_kopylov_not_blocked() -> None:
"""kopylov с used=100 не получает 429."""
db = _db_with_used(100)
check_and_raise(db, "kopylov")
def test_increment_admin_is_noop() -> None:
"""increment для admin → никаких db.execute вызовов."""
db = MagicMock()
increment(db, "admin")
db.execute.assert_not_called()
db.commit.assert_not_called()
def test_increment_kopylov_is_noop() -> None:
"""increment для kopylov → no-op."""
db = MagicMock()
increment(db, "kopylov")
db.execute.assert_not_called()
# ---------------------------------------------------------------------------
# (b) pilot-юзер блокируется на 16-м запросе
# ---------------------------------------------------------------------------
def test_check_and_raise_pilot_not_blocked_at_14() -> None:
"""used=14 < 15 → не блокируется."""
db = _db_with_used(14)
check_and_raise(db, "user1") # должно пройти без исключения
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
Логика: used >= MONTHLY_LIMIT block. После 15-й успешной оценки
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
"""
db = _db_with_used(MONTHLY_LIMIT)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user1")
assert exc_info.value.status_code == 429
assert exc_info.value.detail == LIMIT_EXHAUSTED_MESSAGE
def test_check_and_raise_pilot_blocked_exact_detail() -> None:
"""Проверяем точный текст сообщения 429."""
db = _db_with_used(MONTHLY_LIMIT)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user3")
assert exc_info.value.detail == (
"Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым."
)
def test_check_and_raise_pilot_blocked_over_limit() -> None:
"""used=20 тоже блокируется."""
db = _db_with_used(20)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user2")
assert exc_info.value.status_code == 429
# ---------------------------------------------------------------------------
# (c) increment растит used
# ---------------------------------------------------------------------------
def test_increment_pilot_calls_upsert() -> None:
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
db = MagicMock()
increment(db, "user1")
db.execute.assert_called_once()
db.commit.assert_called_once()
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
call_args = db.execute.call_args
sql_text = str(call_args[0][0]) # first positional arg — text() object
assert "ON CONFLICT" in sql_text
assert "used" in sql_text
def test_increment_none_username_is_noop() -> None:
"""None username → no-op."""
db = MagicMock()
increment(db, None)
db.execute.assert_not_called()
def test_increment_pilot_first_estimate_of_month() -> None:
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
db = MagicMock()
increment(db, "user5")
db.execute.assert_called_once()
# ---------------------------------------------------------------------------
# (d) get_status корректен
# ---------------------------------------------------------------------------
def test_get_status_none_username() -> None:
"""username None → unlimited, used=0, remaining=15."""
db = MagicMock()
status = get_status(db, None)
assert status["unlimited"] is True
assert status["used"] == 0
assert status["remaining"] == MONTHLY_LIMIT
assert status["limit"] == MONTHLY_LIMIT
db.execute.assert_not_called()
def test_get_status_admin() -> None:
"""admin → unlimited, remaining=15 вне зависимости от used."""
db = _db_with_used(7)
status = get_status(db, "admin")
assert status["unlimited"] is True
assert status["remaining"] == MONTHLY_LIMIT
assert status["used"] == 7 # фактический used из БД
def test_get_status_pilot_with_used() -> None:
"""pilot с used=10 → remaining=5."""
db = _db_with_used(10)
status = get_status(db, "user2")
assert status["unlimited"] is False
assert status["used"] == 10
assert status["remaining"] == 5
assert status["limit"] == MONTHLY_LIMIT
def test_get_status_pilot_no_row_yet() -> None:
"""Новый месяц — строки нет → used=0, remaining=15."""
db = _db_no_row()
status = get_status(db, "user3")
assert status["used"] == 0
assert status["remaining"] == MONTHLY_LIMIT
assert status["unlimited"] is False
def test_get_status_pilot_exhausted() -> None:
"""used=15 → remaining=0."""
db = _db_with_used(MONTHLY_LIMIT)
status = get_status(db, "user4")
assert status["remaining"] == 0
assert status["unlimited"] is False
def test_get_status_pilot_over_limit_remaining_zero() -> None:
"""used=20 → remaining=0 (не отрицательное)."""
db = _db_with_used(20)
status = get_status(db, "user5")
assert status["remaining"] == 0
# ---------------------------------------------------------------------------
# (e) отсутствие заголовка = unlimited (через /quota endpoint)
# ---------------------------------------------------------------------------
def test_check_and_raise_no_header_is_noop() -> None:
"""Без заголовка (None) → no-op, не поднимает HTTPException."""
db = MagicMock()
check_and_raise(db, None) # не должно поднять исключение
db.execute.assert_not_called()
# ---------------------------------------------------------------------------
# Integration: /quota endpoint через TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def quota_app() -> FastAPI:
"""Минимальное FastAPI app с /quota endpoint и мок-БД."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_no_row()
application.dependency_overrides[get_db] = _override_db
return application
def test_quota_endpoint_no_header(quota_app: FastAPI) -> None:
"""GET /quota без заголовка → unlimited=True, remaining=15."""
client = TestClient(quota_app)
resp = client.get("/api/v1/trade-in/quota")
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is True
assert data["remaining"] == MONTHLY_LIMIT
assert data["used"] == 0
def test_quota_endpoint_with_pilot_user(quota_app: FastAPI) -> None:
"""GET /quota с user1 (pilot, нет строки в БД) → unlimited=False, used=0."""
client = TestClient(quota_app)
resp = client.get(
"/api/v1/trade-in/quota",
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is False
assert data["used"] == 0
assert data["remaining"] == MONTHLY_LIMIT
def test_quota_endpoint_admin_unlimited(quota_app: FastAPI) -> None:
"""GET /quota с admin → unlimited=True."""
client = TestClient(quota_app)
resp = client.get(
"/api/v1/trade-in/quota",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is True
# ---------------------------------------------------------------------------
# Integration: POST /estimate quota enforcement через TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def estimate_app_exhausted() -> FastAPI:
"""FastAPI app где БД возвращает used=15 для pilot → 429 на POST /estimate."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_with_used(MONTHLY_LIMIT)
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_pilot_blocked_429(estimate_app_exhausted: FastAPI) -> None:
"""POST /estimate с user1 при used=15 → 429 с нужным detail."""
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 429
assert resp.json()["detail"] == LIMIT_EXHAUSTED_MESSAGE
def test_estimate_admin_not_blocked(estimate_app_exhausted: FastAPI) -> None:
"""POST /estimate с admin при used=15 → не 429 (blocked by quota), но может падать
с другой ошибкой (нет реального estimator). Ключевое: не 429."""
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code != 429
def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None:
"""POST /estimate без заголовка → не 429 (fail-open, dev-режим)."""
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
)
assert resp.status_code != 429

View file

@ -7,6 +7,7 @@
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import "@/components/trade-in/trade-in.css";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
@ -27,6 +28,7 @@ import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
function useEstimateId() {
if (typeof window === "undefined") return null;
@ -36,6 +38,17 @@ function useEstimateId() {
export default function TradeInPage() {
const router = useRouter();
const queryClient = useQueryClient();
const quota = useQuota();
const blocked = quota.data
? !quota.data.unlimited && quota.data.remaining <= 0
: false;
const remainingQuota =
quota.data && !quota.data.unlimited ? quota.data.remaining : null;
const limitQuota =
quota.data && !quota.data.unlimited ? quota.data.limit : null;
const [freshResult, setFreshResult] = useState<{
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
@ -59,6 +72,7 @@ export default function TradeInPage() {
setFreshResult({ estimate, input });
// basePath auto-prefixes — passing "/" returns user to /trade-in/?id=...
router.replace(`/?id=${estimate.estimate_id}`, { scroll: false });
void queryClient.invalidateQueries({ queryKey: ["trade-in", "quota"] });
},
});
}
@ -142,7 +156,14 @@ export default function TradeInPage() {
<div className="layout">
{/* Sticky form left */}
<aside className="form-card">
<EstimateForm onSubmit={handleSubmit} isPending={isPending} error={apiError} />
<EstimateForm
onSubmit={handleSubmit}
isPending={isPending}
error={apiError}
blocked={blocked}
remaining={remainingQuota}
limit={limitQuota}
/>
</aside>
{/* Result column */}
@ -205,7 +226,7 @@ export default function TradeInPage() {
<footer className="page-foot">
<div>
PRINZIP Trade-In · MVP ·{" "}
Trade-In · MVP ·{" "}
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
</div>
<div style={{ display: "flex", gap: 18 }}>

View file

@ -14,6 +14,9 @@ interface Props {
onSubmit: (input: TradeInEstimateInput) => void;
isPending: boolean;
error: string | null;
blocked?: boolean;
remaining?: number | null;
limit?: number | null;
}
interface FormState {
@ -92,12 +95,19 @@ function toPayload(f: FormState): TradeInEstimateInput {
return out;
}
export function EstimateForm({ onSubmit, isPending, error }: Props) {
export function EstimateForm({
onSubmit,
isPending,
error,
blocked = false,
remaining = null,
limit = null,
}: Props) {
const [form, setForm] = useState<FormState>(INITIAL);
const [touched, setTouched] = useState(false);
const [mapOpen, setMapOpen] = useState(false);
const valError = validate(form);
const canSubmit = !valError && !isPending;
const canSubmit = !valError && !isPending && !blocked;
function field(key: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
@ -407,37 +417,73 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
</div>
<div className="form-foot">
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary"
style={{ opacity: canSubmit ? 1 : 0.55 }}
>
{isPending ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
{blocked ? (
<div
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 6,
color: "var(--danger)",
fontSize: 12,
marginBottom: 8,
}}
>
Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым.
</div>
) : (
<>
{remaining != null && limit != null && (
<div
style={{
fontSize: 11,
color: "var(--muted)",
marginBottom: 8,
textAlign: "right",
}}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Считаем
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Оценить квартиру
</>
)}
</button>
Осталось{" "}
<span className="mono" style={{ color: "var(--fg-2)" }}>
{remaining}
</span>{" "}
из{" "}
<span className="mono">{limit}</span>{" "}
оценок в этом месяце
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary"
style={{ opacity: canSubmit ? 1 : 0.55 }}
>
{isPending ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Считаем
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Оценить квартиру
</>
)}
</button>
</>
)}
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
<span>Кэш по адресу <span className="num">24 ч</span></span>
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}> готов</span>

View file

@ -60,7 +60,7 @@
a:hover { text-decoration: underline; }
/* ============================================================
HEADER (white-label PRINZIP wordmark slot)
HEADER (white-label wordmark slot)
============================================================ */
.topbar {
position: sticky;

View file

@ -0,0 +1,21 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import type { QuotaStatus } from "@/types/trade-in";
const BASE = "/api/v1/trade-in";
/**
* GET /api/v1/trade-in/quota
* Fetches the monthly evaluation quota status for the current session/account.
* Fail-open: if the query errors, the UI must NOT block the user (treat as unlimited).
*/
export function useQuota() {
return useQuery<QuotaStatus>({
queryKey: ["trade-in", "quota"],
queryFn: () => apiFetch<QuotaStatus>(`${BASE}/quota`),
staleTime: 30_000,
retry: false,
});
}

View file

@ -1,6 +1,14 @@
// Trade-In Estimator — TypeScript types
// Mirrors Pydantic schemas from backend/app/schemas/trade_in.py.
// ── Quota ──
export interface QuotaStatus {
limit: number;
used: number;
remaining: number;
unlimited: boolean;
}
export type HouseType =
| "panel"
| "brick"