- v_bucket_success_score HAVING >= 30 → >= 15 so sparse districts (Кировский etc.) are no longer silently excluded from the block - Backend: SUCCESS_REC_MIN_DEALS=15, SUCCESS_REC_STRONG_DEALS=30; data_confidence='weak' (15-29 deals) or 'strong' (≥30) in response - TS type ParcelSuccessRecommendation: add data_confidence literal - SuccessRecommendationBlock: amber badge for weak, all hex → CSS tokens, emoji star removed (ui-ux.md), empty-state text min 30→15
223 lines
6.1 KiB
TypeScript
223 lines
6.1 KiB
TypeScript
"use client";
|
||
|
||
import type {
|
||
ParcelSuccessRecommendation,
|
||
SuccessRankingBucket,
|
||
} from "@/types/site-finder";
|
||
|
||
interface Props {
|
||
recommendation: ParcelSuccessRecommendation | null | undefined;
|
||
}
|
||
|
||
function successColor(score: number): string {
|
||
if (score > 0.5) return "var(--success)";
|
||
if (score >= 0) return "var(--warn)";
|
||
return "var(--fg-tertiary)";
|
||
}
|
||
|
||
function successBg(score: number): string {
|
||
if (score > 0.5) return "var(--success-soft)";
|
||
if (score >= 0) return "var(--warn-soft)";
|
||
return "var(--bg-card-alt)";
|
||
}
|
||
|
||
function formatPrice(v: number | null): string {
|
||
if (v == null) return "—";
|
||
return (v / 1000).toFixed(0) + " тыс";
|
||
}
|
||
|
||
function formatArea(v: number | null): string {
|
||
if (v == null) return "—";
|
||
return v.toFixed(0) + " м²";
|
||
}
|
||
|
||
function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
|
||
const color = successColor(bucket.success_score);
|
||
const bg = successBg(bucket.success_score);
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
borderRadius: 6,
|
||
padding: "2px 8px",
|
||
background: bg,
|
||
border: "1px solid var(--border-soft)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 36,
|
||
height: 8,
|
||
borderRadius: 4,
|
||
background: "var(--border-strong)",
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
top: 0,
|
||
height: "100%",
|
||
width: `${Math.max(0, Math.min(1, bucket.success_score)) * 100}%`,
|
||
background: color,
|
||
borderRadius: 4,
|
||
}}
|
||
/>
|
||
</div>
|
||
<span style={{ fontSize: 12, fontWeight: 600, color }}>
|
||
{bucket.success_score.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SuccessRecommendationBlock({ recommendation }: Props) {
|
||
if (!recommendation || recommendation.ranking.length === 0) {
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 10,
|
||
padding: "14px 16px",
|
||
background: "var(--bg-card-alt)",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Недостаточно сделок в районе для рейтинга (мин 15)
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const { district, ranking, top_bucket, note, data_confidence } =
|
||
recommendation;
|
||
const isWeak = data_confidence === "weak";
|
||
const top5 = ranking.slice(0, 5);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<div
|
||
style={{ fontSize: 14, fontWeight: 700, color: "var(--fg-primary)" }}
|
||
>
|
||
Что хорошо продаётся в районе {district}
|
||
</div>
|
||
{isWeak && (
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
padding: "2px 8px",
|
||
borderRadius: 6,
|
||
background: "var(--warn-soft)",
|
||
color: "var(--warn)",
|
||
border: "1px solid var(--warn-soft)",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
слабые данные, ориентировочно
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table
|
||
style={{
|
||
width: "100%",
|
||
borderCollapse: "collapse",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<thead>
|
||
<tr>
|
||
{[
|
||
"Бакет",
|
||
"Сделок",
|
||
"Ср. цена ₽/м²",
|
||
"Ср. площадь м²",
|
||
"Успех",
|
||
].map((h) => (
|
||
<th
|
||
key={h}
|
||
style={{
|
||
textAlign: "left",
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
color: "var(--fg-tertiary)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
paddingBottom: 6,
|
||
paddingRight: 12,
|
||
whiteSpace: "nowrap",
|
||
borderBottom: "1px solid var(--border-soft)",
|
||
}}
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{top5.map((row) => {
|
||
const isTop = row.bucket === top_bucket.bucket;
|
||
return (
|
||
<tr key={row.bucket}>
|
||
<td
|
||
style={{
|
||
padding: "6px 12px 6px 0",
|
||
color: "var(--fg-primary)",
|
||
fontWeight: isTop ? 600 : 400,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{isTop ? "* " : ""}
|
||
{row.bucket}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "6px 12px 6px 0",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{row.n_deals}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "6px 12px 6px 0",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{formatPrice(row.avg_price_per_m2)}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "6px 12px 6px 0",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{formatArea(row.avg_area_m2)}
|
||
</td>
|
||
<td style={{ padding: "6px 0 6px 0" }}>
|
||
<SuccessBadge bucket={row} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 11, color: "var(--fg-tertiary)" }}>{note}</div>
|
||
</div>
|
||
);
|
||
}
|