feat(tradein-ui): Yandex admin scraper page — mirror Avito + global delay control #488
1 changed files with 556 additions and 9 deletions
|
|
@ -1,23 +1,570 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import "@/components/trade-in/trade-in.css";
|
import "@/components/trade-in/trade-in.css";
|
||||||
import { Topbar } from "@/components/trade-in/Topbar";
|
import { Topbar } from "@/components/trade-in/Topbar";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ScraperSetting {
|
||||||
|
source: string;
|
||||||
|
request_delay_sec: number;
|
||||||
|
description: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScraperSettingUpdate {
|
||||||
|
source: string;
|
||||||
|
request_delay_sec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrapeAroundInput {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
radius_m: number;
|
||||||
|
sources: string[];
|
||||||
|
multi_room_yandex?: boolean;
|
||||||
|
deep_yandex?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrapeAroundResp {
|
||||||
|
total_fetched: number;
|
||||||
|
total_inserted: number;
|
||||||
|
total_updated: number;
|
||||||
|
by_source: Array<{
|
||||||
|
source: string;
|
||||||
|
fetched: number;
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YandexDetailTriggerResp {
|
||||||
|
ok: boolean;
|
||||||
|
offer_url: string;
|
||||||
|
offer_id: string | null;
|
||||||
|
price_rub: number | null;
|
||||||
|
title: string | null;
|
||||||
|
photo_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YandexNewbuildingTriggerResp {
|
||||||
|
ok: boolean;
|
||||||
|
ext_id: string;
|
||||||
|
ext_slug: string;
|
||||||
|
name: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
ratings_count: number | null;
|
||||||
|
text_reviews_count: number | null;
|
||||||
|
developer_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YandexValuationTriggerResp {
|
||||||
|
ok: boolean;
|
||||||
|
address: string;
|
||||||
|
year_built: number | null;
|
||||||
|
total_floors: number | null;
|
||||||
|
house_type: string | null;
|
||||||
|
has_lift: boolean | null;
|
||||||
|
total_objects: number | null;
|
||||||
|
history_items_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hooks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useScraperSettings() {
|
||||||
|
return useQuery<ScraperSetting[]>({
|
||||||
|
queryKey: ["scraper-settings"],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ settings: ScraperSetting[] }>("/api/v1/admin/scraper-settings").then(
|
||||||
|
(r) => r.settings,
|
||||||
|
),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUpdateScraperSetting() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
ScraperSetting,
|
||||||
|
Error,
|
||||||
|
{ source: string; payload: Omit<ScraperSettingUpdate, "source"> }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ source, payload }) =>
|
||||||
|
apiFetch<ScraperSetting>(`/api/v1/admin/scraper-settings/${source}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ source, ...payload }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scraper-settings"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScrapeAround() {
|
||||||
|
return useMutation<ScrapeAroundResp, Error, ScrapeAroundInput>({
|
||||||
|
mutationFn: (input) =>
|
||||||
|
apiFetch<ScrapeAroundResp>("/api/v1/admin/scrape", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScrapeYandexDetail() {
|
||||||
|
return useMutation<YandexDetailTriggerResp, Error, { offer_url: string }>({
|
||||||
|
mutationFn: ({ offer_url }) =>
|
||||||
|
apiFetch<YandexDetailTriggerResp>(
|
||||||
|
`/api/v1/admin/scrape/yandex-detail?offer_url=${encodeURIComponent(offer_url)}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScrapeYandexNewbuilding() {
|
||||||
|
return useMutation<
|
||||||
|
YandexNewbuildingTriggerResp,
|
||||||
|
Error,
|
||||||
|
{ slug: string; id: string; city: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ slug, id, city }) => {
|
||||||
|
const qs = new URLSearchParams({ slug, id, city });
|
||||||
|
return apiFetch<YandexNewbuildingTriggerResp>(
|
||||||
|
`/api/v1/admin/scrape/yandex-newbuilding?${qs.toString()}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScrapeYandexValuation() {
|
||||||
|
return useMutation<
|
||||||
|
YandexValuationTriggerResp,
|
||||||
|
Error,
|
||||||
|
{ address: string; offer_category: string; offer_type: string; page: number }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ address, offer_category, offer_type, page }) => {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
address,
|
||||||
|
offer_category,
|
||||||
|
offer_type,
|
||||||
|
page: String(page),
|
||||||
|
});
|
||||||
|
return apiFetch<YandexValuationTriggerResp>(
|
||||||
|
`/api/v1/admin/scrape/yandex-valuation?${qs.toString()}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result panel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ResultPanelProps<TData> {
|
||||||
|
mut: {
|
||||||
|
data?: TData;
|
||||||
|
error: Error | null;
|
||||||
|
isPending: boolean;
|
||||||
|
isSuccess: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultPanel<TData>({ mut }: ResultPanelProps<TData>) {
|
||||||
|
if (mut.error) {
|
||||||
|
return (
|
||||||
|
<div className="scraper-result scraper-result--error">
|
||||||
|
<strong>Ошибка:</strong> {mut.error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mut.isSuccess && mut.data) {
|
||||||
|
return (
|
||||||
|
<div className="scraper-result">
|
||||||
|
<pre>{JSON.stringify(mut.data, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function YandexScraperPage() {
|
export default function YandexScraperPage() {
|
||||||
|
// Settings
|
||||||
|
const settingsQ = useScraperSettings();
|
||||||
|
const updateSettingMut = useUpdateScraperSetting();
|
||||||
|
const [delayInput, setDelayInput] = useState<string>("5.0");
|
||||||
|
const delayInitializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const yandex = settingsQ.data?.find((s) => s.source === "yandex");
|
||||||
|
if (yandex && !delayInitializedRef.current) {
|
||||||
|
setDelayInput(String(yandex.request_delay_sec));
|
||||||
|
delayInitializedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [settingsQ.data]);
|
||||||
|
|
||||||
|
// Around
|
||||||
|
const [lat, setLat] = useState("56.8400");
|
||||||
|
const [lon, setLon] = useState("60.6050");
|
||||||
|
const [radius, setRadius] = useState("1500");
|
||||||
|
const [multiRoom, setMultiRoom] = useState(false);
|
||||||
|
const [deep, setDeep] = useState(false);
|
||||||
|
const aroundMut = useScrapeAround();
|
||||||
|
|
||||||
|
// Detail
|
||||||
|
const [detailUrl, setDetailUrl] = useState(
|
||||||
|
"https://realty.yandex.ru/offer/7567094292504417257/",
|
||||||
|
);
|
||||||
|
const detailMut = useScrapeYandexDetail();
|
||||||
|
|
||||||
|
// Newbuilding
|
||||||
|
const [nbSlug, setNbSlug] = useState("tatlin");
|
||||||
|
const [nbId, setNbId] = useState("1592987");
|
||||||
|
const [nbCity, setNbCity] = useState("ekaterinburg");
|
||||||
|
const nbMut = useScrapeYandexNewbuilding();
|
||||||
|
|
||||||
|
// Valuation
|
||||||
|
const [valAddress, setValAddress] = useState(
|
||||||
|
"Свердловская область, Екатеринбург, улица Учителей, 18",
|
||||||
|
);
|
||||||
|
const [valCategory, setValCategory] = useState("APARTMENT");
|
||||||
|
const [valType, setValType] = useState("SELL");
|
||||||
|
const [valPage, setValPage] = useState("1");
|
||||||
|
const valMut = useScrapeYandexValuation();
|
||||||
|
|
||||||
|
const yandexSetting = settingsQ.data?.find((s) => s.source === "yandex");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Topbar active="yandex" />
|
<Topbar active="yandex" />
|
||||||
<main className="page scraper-page">
|
<main className="page scraper-page">
|
||||||
<h1 className="scraper-h1">Yandex скрапер</h1>
|
<h1 className="scraper-h1">Yandex скрапер — admin триггер</h1>
|
||||||
<p className="scraper-subtitle">В разработке.</p>
|
<p className="scraper-subtitle">
|
||||||
<div className="scraper-section">
|
Ручной запуск sub-pipeline'ов YandexRealtyScraper v1 + глобальная настройка
|
||||||
<p style={{ color: "var(--muted, #5b6066)" }}>
|
задержки между запросами.
|
||||||
Yandex SERP refactor merged (PR #462), но admin UI ещё не подключён.
|
</p>
|
||||||
Используй cron-scrape.sh или прямой curl к{" "}
|
|
||||||
<code>POST /api/v1/admin/scrape</code> с{" "}
|
{/* 0. Global delay */}
|
||||||
<code>sources: ["yandex"]</code>.
|
<section className="scraper-section scraper-section--highlight">
|
||||||
|
<h2>Глобальная задержка запросов</h2>
|
||||||
|
<p className="scraper-hint">
|
||||||
|
Настройка применяется ко ВСЕМ Yandex-скрейперам (SERP, detail, ЖК, valuation)
|
||||||
|
при создании следующего инстанса (in-process cache 60s). Меняется без рестарта
|
||||||
|
backend.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
{settingsQ.isPending && <p className="scraper-hint">Загрузка настроек…</p>}
|
||||||
|
{settingsQ.error && (
|
||||||
|
<p className="scraper-result scraper-result--error">
|
||||||
|
Ошибка загрузки настроек: {settingsQ.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{yandexSetting && (
|
||||||
|
<div className="schedule-status" style={{ marginBottom: "12px" }}>
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">Текущая задержка:</span>
|
||||||
|
<span>
|
||||||
|
<strong>{yandexSetting.request_delay_sec} сек</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{yandexSetting.updated_at && (
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">Обновлено:</span>
|
||||||
|
<span>{formatTime(yandexSetting.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{yandexSetting.description && (
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">Описание:</span>
|
||||||
|
<span className="run-muted">{yandexSetting.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="scraper-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSettingMut.mutate({
|
||||||
|
source: "yandex",
|
||||||
|
payload: { request_delay_sec: parseFloat(delayInput) },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Задержка (сек, 1–60)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
step={0.5}
|
||||||
|
value={delayInput}
|
||||||
|
onChange={(e) => setDelayInput(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={updateSettingMut.isPending}>
|
||||||
|
{updateSettingMut.isPending ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{updateSettingMut.error && (
|
||||||
|
<div className="scraper-result scraper-result--error">
|
||||||
|
<strong>Ошибка:</strong> {updateSettingMut.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateSettingMut.isSuccess && updateSettingMut.data && (
|
||||||
|
<div className="scraper-result">
|
||||||
|
Сохранено. Новая задержка:{" "}
|
||||||
|
<strong>{updateSettingMut.data.request_delay_sec} сек</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 1. Around point */}
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>1. Search around (общий /admin/scrape)</h2>
|
||||||
|
<p className="scraper-hint">
|
||||||
|
Yandex SERP path-based vtorichka — lat/lon/radius логируется, scraper берёт{" "}
|
||||||
|
<code>/{ekaterinburg}/kupit/kvartira/vtorichniy-rynok/</code>.
|
||||||
|
multi-room split не реализован для DOM-парсера (запросы будут одинаковыми).
|
||||||
|
Deep пробует pages × sorts комбинации.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="scraper-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
aroundMut.mutate({
|
||||||
|
lat: parseFloat(lat),
|
||||||
|
lon: parseFloat(lon),
|
||||||
|
radius_m: parseInt(radius, 10),
|
||||||
|
sources: ["yandex"],
|
||||||
|
multi_room_yandex: multiRoom,
|
||||||
|
deep_yandex: deep,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Lat
|
||||||
|
<input
|
||||||
|
value={lat}
|
||||||
|
onChange={(e) => setLat(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Lon
|
||||||
|
<input
|
||||||
|
value={lon}
|
||||||
|
onChange={(e) => setLon(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Radius (м)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
max={10000}
|
||||||
|
step={100}
|
||||||
|
value={radius}
|
||||||
|
onChange={(e) => setRadius(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={multiRoom}
|
||||||
|
onChange={(e) => setMultiRoom(e.target.checked)}
|
||||||
|
/>
|
||||||
|
multi_room_yandex (5 rooms split)
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deep}
|
||||||
|
onChange={(e) => setDeep(e.target.checked)}
|
||||||
|
/>
|
||||||
|
deep_yandex (5 × 3 × 2 = 30 requests)
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={aroundMut.isPending}>
|
||||||
|
{aroundMut.isPending ? "Скрейпим…" : "Запустить"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ResultPanel mut={aroundMut} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. Detail */}
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>2. Detail page enrichment</h2>
|
||||||
|
<p className="scraper-hint">
|
||||||
|
Product JSON-LD price (exact int) + DOM-секции description/agency/metro/photos.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="scraper-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
detailMut.mutate({ offer_url: detailUrl });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="full">
|
||||||
|
offer_url
|
||||||
|
<input
|
||||||
|
value={detailUrl}
|
||||||
|
onChange={(e) => setDetailUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="https://realty.yandex.ru/offer/..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={detailMut.isPending}>
|
||||||
|
{detailMut.isPending ? "Парсим…" : "Запустить"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ResultPanel mut={detailMut} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3. ЖК Newbuilding */}
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>3. ЖК (Newbuilding) landing</h2>
|
||||||
|
<p className="scraper-hint">
|
||||||
|
URL /{city}/kupit/novostrojka/{slug}-{id}/ — извлекает coords inline,
|
||||||
|
rating, text_reviews_count, developer_name.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="scraper-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
nbMut.mutate({ slug: nbSlug, id: nbId, city: nbCity });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
slug
|
||||||
|
<input
|
||||||
|
value={nbSlug}
|
||||||
|
onChange={(e) => setNbSlug(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="tatlin"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
id
|
||||||
|
<input
|
||||||
|
value={nbId}
|
||||||
|
onChange={(e) => setNbId(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="1592987"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
city
|
||||||
|
<input
|
||||||
|
value={nbCity}
|
||||||
|
onChange={(e) => setNbCity(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="ekaterinburg"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={nbMut.isPending}>
|
||||||
|
{nbMut.isPending ? "Парсим…" : "Запустить"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ResultPanel mut={nbMut} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 4. Valuation */}
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>4. Valuation (anonymous history)</h2>
|
||||||
|
<p className="scraper-hint">
|
||||||
|
Anonymous GET /otsenka-kvartiry-po-adresu-onlayn/ — house meta + до 30 history
|
||||||
|
items на page.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="scraper-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
valMut.mutate({
|
||||||
|
address: valAddress,
|
||||||
|
offer_category: valCategory,
|
||||||
|
offer_type: valType,
|
||||||
|
page: parseInt(valPage, 10),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="full">
|
||||||
|
Адрес
|
||||||
|
<input
|
||||||
|
value={valAddress}
|
||||||
|
onChange={(e) => setValAddress(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Свердловская область, Екатеринбург, улица Учителей, 18"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
offer_category
|
||||||
|
<select
|
||||||
|
value={valCategory}
|
||||||
|
onChange={(e) => setValCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="APARTMENT">APARTMENT</option>
|
||||||
|
<option value="HOUSE">HOUSE</option>
|
||||||
|
<option value="GARAGE">GARAGE</option>
|
||||||
|
<option value="COMMERCIAL">COMMERCIAL</option>
|
||||||
|
<option value="ROOM">ROOM</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
offer_type
|
||||||
|
<select
|
||||||
|
value={valType}
|
||||||
|
onChange={(e) => setValType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="SELL">SELL</option>
|
||||||
|
<option value="RENT">RENT</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
page
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={valPage}
|
||||||
|
onChange={(e) => setValPage(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={valMut.isPending}>
|
||||||
|
{valMut.isPending ? "Запрашиваем…" : "Запустить"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ResultPanel mut={valMut} />
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue