const { useState, useEffect, useMemo, useCallback } = React; // ------------------------------------------------------------ api async function api(path, opts = {}) { const r = await fetch(path, { method: opts.method || (opts.body ? "POST" : "GET"), headers: opts.body ? { "Content-Type": "application/json" } : {}, body: opts.body ? JSON.stringify(opts.body) : undefined, credentials: "same-origin", }); let data = null; try { data = await r.json(); } catch (e) {} if (r.status === 401 && path !== "/api/login") { window.dispatchEvent(new Event("vc-logout")); } if (!r.ok) { const err = new Error((data && data.detail) || `Ошибка ${r.status}`); err.status = r.status; throw err; } return data; } // ------------------------------------------------------------ роутинг по hash function parseHash() { const [path, qs] = (location.hash.replace(/^#/, "") || "/").split("?"); const q = Object.fromEntries(new URLSearchParams(qs || "")); return { parts: path.split("/").filter(Boolean), q }; } function go(path) { location.hash = path; } function useRoute() { const [r, setR] = useState(parseHash()); useEffect(() => { const f = () => setR(parseHash()); addEventListener("hashchange", f); return () => removeEventListener("hashchange", f); }, []); return r; } const fmtTime = (iso) => { if (!iso) return ""; const d = new Date(iso), now = new Date(); const hm = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); if (d.toDateString() === now.toDateString()) return `сегодня ${hm}`; const y = new Date(now); y.setDate(now.getDate() - 1); if (d.toDateString() === y.toDateString()) return `вчера ${hm}`; return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }) + " " + hm; }; const PROV = { mattrio: "MATTRIO", shichedao: "SHICHEDAO" }; function ConfirmModel({ v, onDone }) { // Подтверждают свои. Подтверждённое перестаёт быть предположением — и для всех, // кто откроет эту машину потом. const [busy, setBusy] = useState(""); const [err, setErr] = useState(""); const [other, setOther] = useState(false); if (!v || !v.vin) return null; const save = async (model) => { setBusy(model); setErr(""); try { await api("/api/confirm", { body: { vin: v.vin, model } }); onDone && onDone(); } catch (e) { setErr(e.message); } finally { setBusy(""); } }; if (v.confirmed) return (
Модель подтвердил {v.confirmed.who}{v.confirmed.at ? `, ${fmtTime(v.confirmed.at)}` : ""}.{" "} { e.preventDefault(); await api(`/api/confirm/${v.vin}`, { method: "DELETE" }); onDone && onDone(); }}>снять
); return ( <>
{v.models_many && Знаете точно? Подтвердите:} {v.models_many && v.models.map((m) => ( ))} {/* «Другая машина» нужна и тогда, когда кандидат один: ошибиться каталог может и уверенно */} {err && {err}}
{other && { setOther(false); onDone && onDone(); }} />} ); } // Расхождение, а не переименование: каталог останется прежним, и под чужим именем он врал бы // убедительнее. Поэтому машину только помечаем и зовём разбираться людей. function OtherCar({ v, onDone }) { const [names, setNames] = useState(null); const [model, setModel] = useState(""); const [src, setSrc] = useState("ПТС"); const [busy, setBusy] = useState(false), [err, setErr] = useState(""); useEffect(() => { api("/api/models").then(setNames).catch(() => setNames([])); }, []); const send = async (e) => { e.preventDefault(); setBusy(true); setErr(""); try { await api("/api/mismatch", { body: { vin: v.vin, model, source: src } }); onDone && onDone(); } catch (x) { setErr(x.message); } finally { setBusy(false); } }; return (
Это другая машина
Имя машины мы не поменяем: каталог остался тем же. Пометим VIN и разберёмся.
setModel(e.target.value)} /> {(names || []).map((n) =>
{err &&
{err}
}
); } function MaybeModels({ v, compact }) { if (!v || !v.models_many) return null; return (
Возможные модели, уточните по машине:{" "} {v.models.join(" · ")} {!compact &&
Поставщик отдаёт одно название на несколько российских моделей, и VIN между ними не различает. Подтвердить модель может только владелец — по документам или шильдику.
}
); } function plural(n, one, few, many) { const a = Math.abs(n) % 100, b = a % 10; if (a > 10 && a < 20) return many; if (b > 1 && b < 5) return few; if (b === 1) return one; return many; } const count = (n, one, few, many) => `${n} ${plural(n, one, few, many)}`; // Кто из наших: только им показываем служебные подсказки вроде «админка → Модели». let STAFF = false; const carTitle = (v) => v.alias || v.untranslated || [v.brand_ru || v.brand, v.sys].filter(Boolean).join(" "); // ------------------------------------------------------------ шапка function Top({ me, ctx, onLogout }) { return (
ПОДБОР ПО VIN {ctx && {ctx}}
{me.admin && админка} {me.usage && me.usage.requests_limit >= 0 && тариф и оплата} запросов VIN осталось VIN: {me.quota && me.quota.left != null ? me.quota.left : "—"} {me.usage && me.usage.requests_limit >= 0 && ( // внутреннему арендатору остаток не показываем: у него лимитов нет осталось {Math.max(0, me.usage.requests_limit - me.usage.requests_used)} номеров {" · "}{Math.max(0, me.usage.vin_limit - me.usage.vin_used)} машин )}
); } // ------------------------------------------------------------ 0. вход по ПИН function LoginByLink({ token, to }) { const [err, setErr] = useState(""); useEffect(() => { api("/api/login/link", { body: { token } }) // из бота приходят и «просто вход», и «вход сразу на оплату» — второе не должно // заставлять человека искать нужный раздел руками .then(() => { location.hash = to ? `#/${to}` : "#/"; location.reload(); }) .catch((e) => setErr(e.message)); }, [token]); return
{err ? <>
{err}

Напишите боту /start — он пришлёт свежую ссылку.

:
Входим…
}
; } function Login({ onIn }) { const [pin, setPin] = useState(""), [err, setErr] = useState(""), [busy, setBusy] = useState(false); const submit = async (e) => { e.preventDefault(); setBusy(true); setErr(""); try { onIn(await api("/api/login", { body: { pin } })); } catch (x) { setErr(x.message); setPin(""); } setBusy(false); }; return (

Подбор по VIN

Введите ваш ПИН

setPin(e.target.value)} autoFocus /> {err &&
{err}
}
); } // ------------------------------------------------------------ 1. главная: VIN + последние // Ошибки браузера уходят в наш журнал: про белый экран надо узнавать от сервиса, а не от партнёра. const _reported = new Set(); function reportError(message, where, stack) { const key = String(message).slice(0, 120); if (_reported.has(key) || _reported.size > 8) return; _reported.add(key); try { fetch("/api/client-error", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ message: String(message), where, stack: String(stack || ""), url: location.href }), }).catch(() => {}); } catch (e) { /* доложить не вышло — не роняем ещё и этим */ } } addEventListener("error", (e) => reportError(e.message, "window.onerror", e.error && e.error.stack)); addEventListener("unhandledrejection", (e) => reportError( (e.reason && e.reason.message) || e.reason, "promise", e.reason && e.reason.stack)); // Ошибка в одной строке списка не должна уносить весь экран. class Boundary extends React.Component { constructor(props) { super(props); this.state = { err: null }; } static getDerivedStateFromError(err) { return { err }; } componentDidCatch(err, info) { reportError(err && err.message, this.props.where || "boundary", info && info.componentStack); } render() { if (!this.state.err) return this.props.children; return
Эту позицию показать не удалось — мы уже знаем об ошибке. {this.props.hint ?
{this.props.hint}
: null}
; } } function lsGet(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } } function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } function Home({ me, onQuota }) { const [vin, setVin] = useState(""), [err, setErr] = useState(""), [busy, setBusy] = useState(false); const [fixes, setFixes] = useState([]); // что сервер исправил во введённой строке const [prov, setProv] = useState(me.admin ? lsGet("vc_provider", "auto") : "auto"); const [recent, setRecent] = useState(null); const [top, setTop] = useState(null); useEffect(() => { api("/api/top").then(setTop).catch(() => setTop([])); }, []); const [wmi, setWmi] = useState([]); useEffect(() => { api("/api/recent").then(setRecent).catch(() => setRecent([])); api("/api/wmi").then(setWmi).catch(() => {}); }, []); const clean = vin.replace(/\s/g, "").toUpperCase(); const cached = recent && recent.some((r) => r.vin === clean); const known = clean.length >= 3 ? wmi.find((w) => w.wmi === clean.slice(0, 3) && w.warn !== false) : null; // HJR: префикс знаем, но пугать нечем — VIN разбирается const local = known || (clean.length >= 3 && clean[0] !== "L" && !known ? { wmi: clean.slice(0, 3), generic: true } : null); const submit = async (e) => { e.preventDefault(); setErr(""); setFixes([]); setBusy(true); try { // разбор строки — на сервере: он же чистит, он же объясняет, что не так const c = await api("/api/vin/check", { body: { vin } }); setFixes(c.fixes || []); if (!c.ok) { setErr(c.error.message); setBusy(false); return; } if ((c.fixes || []).length) setVin(c.vin); const r = await api("/api/vin", { body: { vin: c.vin, provider: prov === "auto" ? undefined : prov } }); onQuota(); go(`/car/${c.vin}?provider=${r.provider}${r.fallback && r.fallback.result === "found" ? `&fb=${r.fallback.from}` : ""}`); } catch (x) { setErr(x.message); } setBusy(false); }; return (

Введите VIN

Система определит модель, год, двигатель и коробку. Размер колёс спросим отдельно — по нему подбираются тормоза.

setVin(e.target.value)} autoFocus spellCheck={false} />
{me.admin && (
Поставщик (только для сравнения, видно вам): {[["auto", "авто по марке"], ["mattrio", "MATTRIO"], ["shichedao", "SHICHEDAO"]].map(([k, t]) => ( ))}
)}
{clean.length === 17 && (cached ? "Этот VIN уже определялся — бесплатно, из памяти." : "Если VIN найдётся — спишется 1 запрос из квоты (ненайденный не списывается). Повторный ввод того же VIN — бесплатно.")}
{local && !cached && (
{local.china ? <>Префикс {local.wmi} — {local.brands}, {local.plant}. Машина китайская, но её VIN поставщики не разбирают. : <>{local.generic ? Префикс {local.wmi} нам незнаком. Чаще всего это сборка вне Китая, но бывают исключения (Jetour китайской сборки — HJR). : Префикс {local.wmi} — {local.brands}, сборка: {local.plant}.} Китайские каталоги VIN местной сборки обычно не знают.} {" "}Скорее всего, машина не найдётся. Квоту это не тратит: поставщик списывает запрос только за найденный VIN. {local.note}
)} {fixes.length > 0 && (
Поправили ввод:
    {fixes.map((f, i) =>
  • {f}
  • )}
)} {err &&
{err}
}
{top && top.length > 0 && (
Часто смотрят
{top.map((c) => (
{c.title}
{c.models_many &&
возможные модели, уточните
}
{[c.year, c.displacement, c.transmission, c.body_ru || c.body, c.fuel_ru].filter(Boolean).join(" · ")}
Двигатель {c.engine || "—"}
{c.vin}
))}
)}
Последние запросы
{recent === null &&
загрузка…
} {recent && !recent.length &&
Пока пусто
} {recent && recent.map((r) => ( {r.vin} {r.found ? {[r.title, r.engine, r.year].filter(Boolean).join(" · ")} {r.provider === "shichedao" ? "SHICHEDAO" : "MATTRIO"} : {r.local ? (r.local.generic ? "не найдено — незнакомый префикс" : `не найдено — местная сборка ${r.local.brands} (${r.local.plant})`) : "не найдено в базе"}} {fmtTime(r.last_access)} ))}
); } // ------------------------------------------------------------ 2. машина function RegistryBlock({ vin }) { const [r, setR] = useState(null); const [err, setErr] = useState(""); useEffect(() => { setR(null); setErr(""); api(`/api/registry/${vin}`).then(setR).catch((e) => setErr(e.message)); }, [vin]); if (err) return
Реестр ОТТС не ответил: {err}. Это сбой источника, а не ответ «такой машины нет».
; if (!r) return
Смотрим в реестре одобрений типа ТС…
; const hits = r.hits || []; const one = hits.length === 1 ? hits[0] : null; return (
Реестр ОТТС{" "} {hits.length === 0 && <>— ни одна маска одобрения на этот VIN не легла{r.total ? ` (проверили ${r.checked} кандидатов из ${r.total})` : ""}.} {one && (one.specificity >= 8 ? <>— это {one.brand} {one.name || ""}. : <>— марка {one.brand}. Модель реестр не уточняет: в маске {one.mask} заданы только первые знаки.)} {hits.length > 1 && <>— подошло {hits.length} одобрений, машина одна из них:} {hits.length > 1 && ( )} {one &&
Одобрение {one.number}, маска {one.mask}{one.mods ? `. Модификации: ${one.mods}` : ""}
} {r.truncated &&
Кандидатов в реестре больше ({r.total}), смотрели первые {r.checked} — подходящее одобрение могло остаться дальше.
}
); } function Vehicle({ vin, route, setCtx }) { const [v, setV] = useState(null), [err, setErr] = useState(""); const [showAll, setShowAll] = useState(false); const rim = route.q.rim || ""; const mjsid = route.q.mjsid || ""; useEffect(() => { api(`/api/vin/${vin}${route.q.provider ? `?provider=${route.q.provider}` : ""}`).then(setV).catch((x) => setErr(x.message)); }, [vin, route.q.provider]); useEffect(() => { if (v && v.found && v.provider !== "shichedao") setCtx(`${carTitle(v)} · ${v.displacement || ""} · ${v.vin_year || ""}`); }, [v]); if (err) return
{err}
; if (!v) return
загрузка…
; if (v.found && v.provider === "shichedao") return ; if (!v.found) return (

{vin}

{v.tried && v.tried.length > 1 ? <>Проверили оба поставщика — {v.tried.map((x) => PROV[x] || x).join(" и ")}: этого VIN нет ни у одного. : <>Поставщик не нашёл этот VIN в своей базе (код {v.code}, «{v.msg}»).}

{v.local ? (
{v.local.china ? <>Почему: префикс {v.local.wmi} — это {v.local.brands}, {v.local.plant}. Машина китайская, но её VIN поставщики не разбирают — так бывает у отдельных марок. {v.local.note} : <>{v.local.generic ? Почему: префикс {v.local.wmi} нам незнаком — скорее всего, сборка вне Китая либо марка не покрыта поставщиком. : Почему: префикс {v.local.wmi} — это {v.local.brands}, сборка «{v.local.plant}».} Китайские каталоги знают VIN китайских заводов, а у машин местной сборки свой код завода — такие VIN в их базе отсутствуют. {v.local.note}}
) : (
Префикс {vin.slice(0, 3)} в нашем списке местных сборок не значится. Возможные причины: марка не покрыта поставщиком, ошибка в VIN, или это местная сборка, которой ещё нет в списке (Вадим может добавить её в админке → Префиксы).
)} {v.local && v.local.donor && (
Это {v.local.donor.brand} {v.local.donor.model} — узнали по префиксу {v.local.donor.prefix}. {v.local.donor.note} {v.local.donor.donor_vin ? (
Номера деталей будут от китайского VIN {v.local.donor.donor_vin}. Платформа совпадает по позициям 4-8 VIN, но это не гарантия: сверяйте деталь по фото и размеру.
) :
Китайской машины этой модели у нас пока не было — как встретится, Вадим добавит её в админке → Доноры.
}
)} {v.local && v.local.scd_models && (
Можно открыть каталог по модели. {v.local.china ? <>Поставщик не разобрал этот VIN, но каталог {v.local.brands} у SHICHEDAO есть — откройте его по модели. : <>У SHICHEDAO есть версии {v.local.brands} для российского рынка — выберите модель машины.}
Точность ниже, чем по VIN: каталог покажет все варианты модели, комплектацию сверяйте по машине.
)} {v.local && (v.local.vds_donors || []).length > 0 && (
Похожие по платформе — это предположение, не факт. У этих китайских машин те же позиции 4-8 VIN ({v.local.vds}), значит платформа, скорее всего, общая. Модель это не доказывает: одни и те же позиции встречаются у разных моделей. Сверяйте деталь по фото и размеру.
    {v.local.vds_donors.map((g) => (
  • {g.model_ru || g.model}{g.model_ru ? {g.model} : null}{" "} {g.vins.map((x) => ( ))}
  • ))}
)}

Ответ сохранён — повторный ввод бесплатный. Другой VIN

); const setQ = (patch) => { const q = new URLSearchParams({ ...route.q, ...patch }); [...q.keys()].forEach((k) => { if (!q.get(k)) q.delete(k); }); go(`/vin/${vin}?${q}`); }; const matches = (t) => !rim || t.rim === rim; // год из VIN сужает набор; если ни одна комплектация под него не подошла — показываем все const yearKnown = v.trims.some((t) => t.vin_year); const vinYear = yearKnown ? v.trims.filter((t) => t.vin_year) : v.trims; const other = yearKnown ? v.trims.filter((t) => !t.vin_year) : []; const sorted = (list) => [...list].sort((a, b) => (matches(b) - matches(a))); const selected = v.trims.find((t) => t.mjsid === mjsid); const rimMatchVinYear = rim ? vinYear.filter(matches) : []; const TrimRow = (t) => ( ); return (

{v.confirmed ? v.confirmed.model : carTitle(v)}

api(`/api/vin/${vin}`).then(setV).catch(() => {})} /> {!v.alias &&
{v.untranslated || `${v.brand} ${v.sys}`} — без перевода {STAFF && " (русское название задаётся в админке → Модели)"}
}
VIN
{v.vin}
Двигатель
{[v.engine, v.displacement].filter(Boolean).join(" · ")}
Коробка
{v.transmission || "—"}
Год по VIN
{v.vin_year || "—"}
Кузов
{[v.body, v.fuel].filter(Boolean).join(" · ")}
Размер колёс — по нему подбираются тормоза
Спросите клиента: {v.rims.map((r) => ( ))}
VIN размер колёс не определяет. {v.rims.map((r) => `${r.rim}: ${r.tyres.join(", ")}`).join(" · ")}
По VIN поставщик отдаёт {count(v.trims.length, "комплектацию", "комплектации", "комплектаций")} за {v.years.join(", ")} гг. — до одной не сужает. {rim && rimMatchVinYear.length === 1 && <> Для {v.vin_year} года и {rim} подходит одна — выберите её.} {rim && rimMatchVinYear.length > 1 && <> Для {v.vin_year} года и {rim} подходят {rimMatchVinYear.length}: для тормозов разницы обычно нет, для салона и электрики может понадобиться уточнение.}
{yearKnown ? `Комплектации ${v.vin_year} года` : "Все комплектации (год из VIN ни с одной не совпал)"}{rim ? ` · ${rim} выделены` : ""}
{sorted(vinYear).map(TrimRow)} {!showAll && other.length > 0 && ( )} {showAll && sorted(other).map(TrimRow)}
{!selected && {rim ? "Выберите комплектацию" : "Сначала размер колёс, потом комплектация"}}
); } // ------------------------------------------------------------ копирование номера async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (e) {} try { // запасной путь: старые браузеры / не-https const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok; } catch (e) { return false; } } function PartNumber({ p, big, small, onRevealed }) { // Номера в ответе нет, пока он не раскрыт — только идентификатор и хвост для узнавания. const [num, setNum] = useState(p.partNumber || null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); useEffect(() => { setNum(p.partNumber || null); }, [p.pn_id, p.partNumber]); const reveal = async () => { setBusy(true); setErr(""); try { // ключ и группу шлём для статистики спроса: без них видно «раскрыли номер», но не «что искали» const r = await api("/api/reveal", { body: { pn_id: p.pn_id, source: p.src || "card", key: p.key, group: p.grp || null } }); setNum(r.part_number); if (onRevealed) onRevealed(r); // замены открываются тем же нажатием, списание одно await copyText(r.part_number); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; if (!num) return ( <> {p.pn_tail && ···{p.pn_tail}} {p.has_subs && есть замены — откроются вместе с номером} {err && {err}} ); return ( <> {num} ); } // Копом раскрывать нельзя: платят за то, что нажали. Берём только уже раскрытые. function copyRevealed(list) { const ok = (list || []).map((p) => p.partNumber).filter(Boolean); if (ok.length) copyText(ok.join("\n")); return ok.length; } function CopyBtn({ text, label = "Скопировать номер", small }) { const [done, setDone] = useState(null); const click = async (e) => { e.stopPropagation(); setDone(await copyText(text) ? "ok" : "err"); setTimeout(() => setDone(null), 1600); }; return ( ); } // ------------------------------------------------------------ подсказки в поле поиска детали let DICT_CACHE = null; const PARTNO_RE = /^(?=.*\d)[A-Z0-9][A-Z0-9-]{4,24}$/; // тот же формат, что на сервере const normRu = (s) => s.toLowerCase().replace(/ё/g, "е").replace(/\s+/g, " ").trim(); function rankSuggest(dict, q) { // с начала → с начала слова → внутри; внутри группы — что чаще реально искали, потом короче const score = (ru) => ru.startsWith(q) ? 0 : ru.split(" ").some((w) => w.startsWith(q)) ? 1 : ru.includes(q) ? 2 : -1; return dict.map((d) => [score(d.ru), d]).filter(([s]) => s >= 0) .sort((a, b) => a[0] - b[0] || b[1].n - a[1].n || a[1].ru.length - b[1].ru.length || a[1].ru.localeCompare(b[1].ru, "ru")) .map(([, d]) => d); } function Hl({ text, q }) { const i = text.indexOf(q); if (i < 0 || !q) return text; return <>{text.slice(0, i)}{text.slice(i, i + q.length)}{text.slice(i + q.length)}; } function SuggestInput({ id, value, onChange, onPick, placeholder, autoFocus }) { const [dict, setDict] = useState(DICT_CACHE); const [open, setOpen] = useState(false), [hi, setHi] = useState(-1); useEffect(() => { if (!DICT_CACHE) api("/api/dict/suggest").then((d) => { DICT_CACHE = d; setDict(d); }).catch(() => {}); }, []); const qn = normRu(value); const isNum = PARTNO_RE.test(value.replace(/\s/g, "").toUpperCase()); const isZh = /[㐀-鿿]/.test(value); const items = useMemo(() => (!dict || qn.length < 2 || isNum || isZh) ? [] : rankSuggest(dict, qn).slice(0, 8), [dict, qn, isNum, isZh]); const pick = (s) => { onChange(s.ru); setOpen(false); setHi(-1); onPick(s.ru); }; const onKey = (e) => { if (e.key === "Escape") { setOpen(false); return; } if (e.key === "Enter") { if (open && hi >= 0 && items[hi]) { e.preventDefault(); pick(items[hi]); } else setOpen(false); return; } if (!items.length) return; if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHi((hi + 1) % items.length); } else if (e.key === "ArrowUp") { e.preventDefault(); setOpen(true); setHi(hi <= 0 ? items.length - 1 : hi - 1); } }; const lid = id + "-list"; return (
0} aria-controls={lid} aria-activedescendant={hi >= 0 ? `${lid}-${hi}` : undefined} onChange={(e) => { onChange(e.target.value); setOpen(true); setHi(-1); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} onKeyDown={onKey} /> {open && items.length > 0 && (
    {items.map((s, i) => (
  • { e.preventDefault(); pick(s); }} onMouseEnter={() => setHi(i)}> {/* китайский ключ — чтобы было видно, если словарь врёт */} {s.zh.join(" · ")} {s.zero && на проверке 0 деталей} {s.n > 0 && искали {s.n}}
  • ))}
)} {open && isNum &&
Поставщик по номеру детали не ищет — введите название
} {open && dict && qn.length >= 2 && !isNum && !isZh && !items.length && (
Такого слова в словаре нет. Если нажмёте «Найти», запрос запишется — словарь пополним по нему.
)}
); } // ------------------------------------------------------------ живой поиск: при наборе — сразу детали машины const LIVE_CACHE = new Map(); const LIVE_CFG = { mattrio: { min: 3, delay: 450, max: 3 }, shichedao: { min: 4, delay: 800, max: 2 } }; // SHICHEDAO платный — осторожнее function liveKeys(dict, qn, max) { // словарь невидимо: те же правила ранжирования, что у подсказок; берём первые max разных китайских ключей const keys = [], words = {}; for (const d of rankSuggest(dict, qn)) for (const z of d.zh) { if (keys.length < max && !keys.includes(z)) { keys.push(z); words[z] = d.ru; } } return { keys, words }; } function LiveInput({ id, value, onChange, veh, onPick, placeholder }) { const cfg = LIVE_CFG[veh.provider]; const [dict, setDict] = useState(DICT_CACHE); const [open, setOpen] = useState(false), [hi, setHi] = useState(-1); const [items, setItems] = useState([]), [state, setState] = useState("idle"); const latest = React.useRef(""); useEffect(() => { if (!DICT_CACHE) api("/api/dict/suggest").then((d) => { DICT_CACHE = d; setDict(d); }).catch(() => {}); }, []); const vehKey = veh.provider === "shichedao" ? `scd:${veh.scd_id}` : `${veh.vin}:${veh.mjsid}`; useEffect(() => { setHi(-1); const qn = normRu(value), compact = value.replace(/\s/g, "").toUpperCase(); const isNum = PARTNO_RE.test(compact), isZh = /[㐀-鿿]/.test(value); if (!isNum && !isZh && qn.length < cfg.min) { setItems([]); setState("idle"); latest.current = ""; return; } let keys = [], words = {}; if (!isNum && !isZh) { if (!dict) return; ({ keys, words } = liveKeys(dict, qn, cfg.max)); if (!keys.length) { setItems([]); setState("nodict"); latest.current = ""; return; } } const ck = `${vehKey}|${isNum ? "oe:" + compact : isZh ? "zh:" + value.trim() : keys.join(",")}`; latest.current = ck; if (LIVE_CACHE.has(ck)) { setItems(LIVE_CACHE.get(ck)); setState("ready"); return; } // «колод» → «колодк»: ключи те же, запроса нет setState("wait"); const tmr = setTimeout(async () => { setState("loading"); try { const body = veh.provider === "shichedao" ? { scd_id: veh.scd_id } : { vin: veh.vin, mjsid: veh.mjsid }; const r = await api("/api/live", { body: { ...body, q: value, keys } }); const its = r.items.map((x) => ({ ...x, word: words[x.key] || value.trim() })); LIVE_CACHE.set(ck, its); if (latest.current === ck) { setItems(its); setState("ready"); } // устаревший ответ не перетирает свежий } catch (e) { if (latest.current === ck) setState("error"); } }, cfg.delay); return () => clearTimeout(tmr); }, [value, dict, vehKey]); const shown = items.slice(0, 15); // Что отличает эту позицию от соседней с тем же названием: исполнение завода, // а если признака нет ни у одной — честно говорим, что это разные исполнения. const nameOf = (x) => x.name_ru || x.name || ""; const featOf = (x) => { const m = x.full && x.full.mfr; return m ? (m.name_ru || m.name) : ""; }; const liveFeature = (it) => { const f = featOf(it); if (f) return { text: f, warn: false }; const same = shown.filter((x) => nameOf(x) === nameOf(it)); if (same.length > 1 && same.every((x) => !featOf(x) && !x.comment_ru)) return { text: "разные исполнения, уточните по машине", warn: true }; return { text: "", warn: false }; }; const pick = (it) => { setOpen(false); onPick(it, items); }; // отдаём весь список: у позиции бывает несколько заводов const onKey = (e) => { if (e.key === "Escape") { setOpen(false); return; } if (e.key === "Enter") { if (open && hi >= 0 && shown[hi]) { e.preventDefault(); pick(shown[hi]); } else setOpen(false); return; } if (!shown.length) return; if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHi((hi + 1) % shown.length); } else if (e.key === "ArrowUp") { e.preventDefault(); setOpen(true); setHi(hi <= 0 ? shown.length - 1 : hi - 1); } }; const lid = id + "-list"; return (
0} aria-controls={lid} aria-activedescendant={hi >= 0 ? `${lid}-${hi}` : undefined} onChange={(e) => { onChange(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} onKeyDown={onKey} /> {open && shown.length > 0 && (
    {shown.map((it, i) => (
  • { e.preventDefault(); pick(it); }} onMouseEnter={() => setHi(i)}> {it.name_ru || it.name} {it.comment_ru && {it.comment_ru}} {it.partNumber || `···${it.pn_tail || ""}`} {liveFeature(it).text}
  • ))} {items.length > shown.length &&
  • ещё {items.length - shown.length} — нажмите «Найти», чтобы увидеть все
  • }
)} {open && (state === "wait" || state === "loading") && !shown.length &&
ищу детали этой машины…
} {open && state === "ready" && !shown.length &&
У этой машины по такому запросу деталей не нашлось.
} {open && state === "nodict" &&
Такого слова в словаре нет. Нажмите «Найти» — запрос запишется, словарь пополним.
} {open && state === "error" &&
Поставщик не ответил — нажмите «Найти».
}
); } // ------------------------------------------------------------ SHICHEDAO function VehicleScd({ v, setCtx }) { useEffect(() => { setCtx(`${v.cars[0].title_ru || v.cars[0].title} · SHICHEDAO`); }, [v]); return (
Найдено по VIN · SHICHEDAO

{v.cars[0].title_ru || v.cars[0].title}

{v.cars[0].path || v.cars[0].title}
VIN
{v.vin}
Год
{v.cars[0].year || "—"}
Двигатель
{[v.cars[0].engine, v.cars[0].engine_desc].filter(Boolean).join(" · ") || "—"}
Коробка
{v.cars[0].gearbox || "—"}
{v.cars.length > 1 &&
VIN подходит к {count(v.cars.length, "варианту", "вариантам", "вариантам")} каталога — выберите нужный.
}
{v.cars.map((c) => ( ))}
); } function ScdBrands({ wmi, setCtx }) { const [d, setD] = useState(null), [err, setErr] = useState(""); useEffect(() => { api(`/api/scd/brands?wmi=${wmi}`).then(setD).catch((x) => setErr(x.message)); setCtx("выбор марки"); }, [wmi]); return (
{err &&
{err}
} {!d && !err &&
загрузка…
} {d && <>

Префикс {d.wmi}: выберите марку

{d.plant}. Под этим префиксом идёт несколько марок ({d.brands}), а VIN поставщики не разбирают. Выберите марку — дальше список моделей и каталог по узлам. Привязки деталей к конкретной машине не будет.
{d.items.map((b) => ( ))} {!d.items.length &&
Марок не найдено
}
}
); } function ScdModels({ wmi, setCtx, brand }) { const [d, setD] = useState(null), [err, setErr] = useState(""); useEffect(() => { api(`/api/scd/models?wmi=${wmi}${brand ? `&brand=${brand}` : ""}`).then(setD).catch((x) => setErr(x.message)); setCtx("выбор модели"); }, [wmi, brand]); const pick = async (m) => { try { const r = await api("/api/scd/manual", { body: { wmi, series_id: m.series_id, brand } }); go(`/scd/${r.id}`); } catch (x) { setErr(x.message); } }; return (
{err &&
{err}
} {!d && !err &&
загрузка…
} {d && <>

{d.brands}: модели российского рынка

Префикс {d.wmi} — {d.plant}. По VIN китайские каталоги эту машину не знают, но у SHICHEDAO есть каталоги российских версий. Выберите модель — откроется каталог по узлам. Привязки деталей к конкретной машине не будет.
{d.models.map((m) => ( ))} {!d.models.length &&
Моделей не найдено
}
}
); } function Replaces({ list }) { // сразу — актуальный новый номер и старые (вытесненные); остальное (у VW бывает 40+) — по кнопке const [all, setAll] = useState(false); const main = list.filter((r) => r.latest || r.kind === "older").slice(0, 3); const rest = list.filter((r) => !main.includes(r)); const shown = all ? main.concat(rest) : (main.length ? main : list.slice(0, 3)); const hidden = list.length - shown.length; return (
Замены{list.length > shown.length || all ? ` (${list.length})` : ""}:
{shown.map((r) => (
{r.number} {r.kind === "newer" ? (r.latest ? "актуальный новый номер" : "новее") : r.kind === "older" ? "старый номер" : "замена"} {r.date ? ` · ${r.date.split("-").reverse().join(".")}` : ""}
))} {(hidden > 0 || all) && }
); } function ScdResults({ res, vid, page, setPage, all }) { const [err, setErr] = useState(""); const jump = async (p) => { try { const r = await api(`/api/scd/v/${vid}/jump`, { body: { detail_id: p.detail_id, part_number: p.partNumber } }); go(`/scd/${vid}/d?${new URLSearchParams({ main: r.main, sub: r.sub, img: r.img, lock: 0 })}`); } catch (x) { setErr(x.message); } }; const pages = all ? 1 : Math.ceil(res.parts.length / PAGE); const parts = all ? res.parts : res.parts.slice(page * PAGE, page * PAGE + PAGE); return (
Найдено {res.parts.length}
{err &&
{err}
} {parts.map((p, i) => (
{p.partNumber} {p.badge === "analog" ? "НЕСКОЛЬКО ВАРИАНТОВ" : BADGE[p.badge].text}
{p.name_ru || перевода нет}{p.name}
{p.group && p.group !== p.name &&
группа: {p.group_ru || p.group}
} {(p.memo || p.remark) &&
{[p.memo, p.remark].filter(Boolean).join(" · ")}
} {p.replaces && p.replaces.length > 0 && }
{p.pos &&
позиция на схеме: {p.pos}
} {p.price4s &&
подробнее
цена у дилера: {p.price4s} ¥
} {p.has_image && }
))} { setPage(i); window.scrollTo({ top: 0, behavior: "smooth" }); }} />
); } function ScdCatalog({ vid, route, setCtx }) { const { main, sub } = route.q; const [v, setV] = useState(null), [mains, setMains] = useState(null), [subs, setSubs] = useState(null), [imgs, setImgs] = useState(null); const [err, setErr] = useState(""), [q, setQ] = useState(""), [res, setRes] = useState(null), [busy, setBusy] = useState(false); const [treeOpen, setTreeOpen] = useState(Boolean(main)); useEffect(() => { api(`/api/scd/v/${vid}`).then((x) => { setV(x); setCtx(`${x.title_ru || x.title} · SHICHEDAO`); }).catch((x) => setErr(x.message)); }, [vid]); const showTree = treeOpen || (v && v.source !== "vin"); // без VIN поиск закрыт — остаётся только каталог useEffect(() => { if (showTree && !mains) api(`/api/scd/v/${vid}/main`).then(setMains).catch((x) => setErr(x.message)); }, [vid, showTree]); useEffect(() => { setSubs(null); if (main) api(`/api/scd/v/${vid}/sub?main=${encodeURIComponent(main)}`).then(setSubs).catch((x) => setErr(x.message)); }, [vid, main]); useEffect(() => { setImgs(null); if (main && sub) api(`/api/scd/v/${vid}/images?main=${encodeURIComponent(main)}&sub=${encodeURIComponent(sub)}`).then(setImgs).catch((x) => setErr(x.message)); }, [vid, main, sub]); const nav = (p) => go(`/scd/${vid}?${new URLSearchParams(p)}`); const openDiagram = (m, s, i, lock) => go(`/scd/${vid}/d?${new URLSearchParams({ main: m, sub: s, img: i, lock: lock ?? 0 })}`); const search = async (e, text) => { if (e) e.preventDefault(); const qq = (text != null ? text : q).trim(); if (!qq) return; setBusy(true); setErr(""); try { setRes(await api(`/api/scd/v/${vid}/search`, { body: { q: qq } })); } catch (x) { setErr(x.message); } setBusy(false); }; const jump = async (p) => { try { const r = await api(`/api/scd/v/${vid}/jump`, { body: { detail_id: p.detail_id, part_number: p.partNumber } }); openDiagram(r.main, r.sub, r.img, 0); } catch (x) { setErr(x.message); } }; const mainName = mains && main && (mains.find((x) => x.id === main) || {}); const subName = subs && sub && (subs.find((x) => x.id === sub) || {}); return (
{v &&
{v.source === "vin" ? <>По VIN {v.vin} : <>Выбрано вручную, без VIN — привязки деталей к машине нет} {" · "}SHICHEDAO
} {err &&
{err}
} {v && v.source === "vin" && (
search(null, s)} placeholder="колодки передние · фильтр масляный · стойка стабилизатора" /> )} {res && res.mode === "no_dict" &&
Слова «{res.q}» нет в словаре. {res.suggestions.length ? "Похожие: " + res.suggestions.join(", ") : ""}
} {res && res.mode !== "no_dict" && {}} all />} {v && v.source === "vin" && !showTree && ( )} {showTree &&
Узлы {main && <>› { e.preventDefault(); nav({ main }); }}>{(mainName && (mainName.name_ru || mainName.name)) || main}} {sub && <>› {(subName && (subName.name_ru || subName.name)) || sub}}
{!main && (mains ?
{mains.map((m) => ( ))}
:
загрузка узлов…
)} {main && !sub && (subs ?
{subs.map((s, i) => ( ))}
:
загрузка…
)} {main && sub && (imgs ?
{imgs.map((g) => ( ))} {!imgs.length &&
Схем нет
}
:
загрузка схем…
)}
}
); } const BADGE_SCD = { exact: "SHICHEDAO: деталь однозначно привязана к этому VIN (lock_flag = 1)", analog: "SHICHEDAO: подходит к нескольким вариантам — сверьте комплектацию (lock_flag = 2)", unknown: "Привязка к машине не определена (выбор модели без VIN или lock_flag 0/3)", }; function Hotspot({ c, w, h, on, label, onClick }) { // координаты в пикселях исходной картинки → проценты, чтобы работало при любом масштабе const st = c.kind === "rect" ? { left: `${c.x1 / w * 100}%`, top: `${c.y1 / h * 100}%`, width: `${(c.x2 - c.x1) / w * 100}%`, height: `${(c.y2 - c.y1) / h * 100}%` } : { left: `calc(${c.x1 / w * 100}% - 16px)`, top: `calc(${c.y1 / h * 100}% - 16px)`, width: 32, height: 32, borderRadius: "50%" }; return } )} {full && d && (
setFull(false)}>
e.stopPropagation()}>{renderImg(true)}
)} ); } // ------------------------------------------------------------ экран машины (главный): карточка · группы · поиск · результаты function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } } function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) {} } const PAGE = 20; function Crumbs({ items }) { return ; } function Fact({ k, v, mono }) { if (v == null || v === "") return null; return
{k}
{v}
; } function Pager({ page, pages, onPage }) { if (pages <= 1) return null; return (
{Array.from({ length: pages }, (_, i) => ( ))}
); } function TrimPicker({ v, trim, onPick, onClose }) { const pool = v.trims.filter((t) => t.vin_year).length ? v.trims.filter((t) => t.vin_year) : v.trims; const [sel, setSel] = useState(() => Object.fromEntries(v.features.map((f) => [f.key, trim.feat[f.key]]))); const fits = (s) => pool.filter((t) => v.features.every((f) => !s[f.key] || t.feat[f.key] === s[f.key])); const match = fits(sel); // выбрал значение, несовместимое с остальными (R19 + передний привод) → остальные подстраиваются под первую подходящую const choose = (key, value) => { const next = { ...sel, [key]: sel[key] === value ? null : value }; if (!next[key] || fits(next).length) { setSel(next); return; } const t = pool.find((x) => x.feat[key] === value); setSel(Object.fromEntries(v.features.map((f) => [f.key, t ? t.feat[f.key] : null]))); }; return (
e.stopPropagation()}> Уточнить комплектацию
Только признаки, которыми комплектации {v.vin_year} года реально различаются. Номера тормозов зависят от колёс и привода.
{v.features.map((f) => (
{f.label}
{f.values.map((x) => ( ))}
))} {!v.features.length &&
Комплектации этого года ничем не различаются.
}
подходит комплектаций: {match.length}
); } function ResultsList({ res, onScheme, page, setPage, vin }) { const groups = useMemo(() => (res ? mergeParts(res.parts) : []), [res]); if (!res) return null; if (res.mode === "no_dict") return (
Слова «{res.q}» нет в словаре — поставщику искать нечего. Запрос записан, словарь пополним. {res.suggestions.length > 0 &&
Похожие: {res.suggestions.join(", ")}
}
); const pages = Math.ceil(groups.length / PAGE); const shown = groups.slice(page * PAGE, page * PAGE + PAGE); return (
{res.mode === "group" ? `Группа «${res.q}»` : res.mode === "oe" ? "Поиск по номеру" : res.mode === "pick" ? `Выбрано из списка · «${res.q}»${res.parts.length > 1 ? ` · ${count(res.parts.length, "вариант", "варианта", "вариантов")} этой позиции` : ""}` : `«${res.q}»`}{res.mode !== "pick" ? ` · найдено ${count(res.parts.length, "деталь", "детали", "деталей")}` : ""} {res.reps && res.reps.length > 1 && сверено по {count(res.reps.length, "комплектации", "комплектациям", "комплектациям")} ({res.reps.map((r) => r.combo).join(" / ")})}
{res.parts.length === 0 &&
Поставщик ничего не нашёл.
} {shown.map((list, i) => ( {list.length > 1 ? : list.map((p) => )} ))} { setPage(i); window.scrollTo({ top: 0, behavior: "smooth" }); }} />
); } // Машина, у которой поставщик не отдаёт деталей ВООБЩЕ. Говорим это один раз и на уровне // машины: гонять человека по двенадцати пустым группам — худший способ сообщить новость. function Mismatch({ v }) { if (!v || !v.mismatch || !v.mismatch.length) return null; const m = v.mismatch[v.mismatch.length - 1]; return (
Каталог может не соответствовать машине. {m.who} сообщил, что это «{m.model}» (источник: {m.source}). Имя машины мы не меняли — каталог остался прежним. Сверяйте детали по самой машине.
); } // Каталога по самой машине нет, но есть точно такая же с рабочим каталогом. Показываем её, // не делая вид, что это каталог клиентского VIN: разница ровно в этом предложении. function Substitute({ sub }) { if (!sub) return null; const what = [sub.model, sub.year, sub.engine].filter(Boolean).join(", "); return (
Каталог такой же машины{what ? ` (${what})` : ""}. Поставщик не привязал детали к вашему VIN, поэтому показываем машину той же группы, мотора и коробки {sub.exact ? " и того же года" : " и соседнего года выпуска"}. Номера сверяйте по снятой детали.
); } function NoParts({ v }) { if (!v || !v.no_parts || v.substitute) return null; return (
По этой машине у поставщика нет каталога деталей. Проверены две группы — пусто в обеих, значит пусто и в остальных. VIN разобран верно, но детали к нему не привязаны.
Что делать: искать по машине-донору — той же платформы, с китайским VIN. Ручной выбор марки и модели скоро появится здесь же.
); } function CarScreen({ vin, route, setCtx, setLastSearch }) { const provider = route.q.provider || ""; const [v, setV] = useState(null), [err, setErr] = useState(""); const [groups, setGroups] = useState([]), [grp, setGrp] = useState(""); const [q, setQ] = useState(""), [res, setRes] = useState(null), [busy, setBusy] = useState(""), [page, setPage] = useState(0); const [picker, setPicker] = useState(false), [mjsid, setMjsid] = useState(null); const [carMore, setCarMore] = useState(false); // китайское название и поколение — по запросу useEffect(() => { api(`/api/vin/${vin}${provider ? `?provider=${provider}` : ""}`).then(setV).catch((x) => setErr(x.message)); api("/api/groups").then(setGroups).catch(() => {}); }, [vin, provider]); useEffect(() => { if (!v) return; if (!v.found) { go(`/vin/${vin}?provider=${v.provider}`); return; } // не найден → экран с причиной и выбором модели if (v.provider === "mattrio") setMjsid(ssGet(`vc_trim_${vin}`) || v.trims[0].mjsid); setCtx(v.provider === "shichedao" ? (v.cars[0].title_ru || v.cars[0].title) : `${carTitle(v)} · ${v.displacement || ""} · ${v.vin_year || ""}`); }, [v]); if (err) return
{err}
; if (!v || !v.found || (v.provider === "mattrio" && !mjsid)) return
загрузка…
; const scd = v.provider === "shichedao"; const car = scd ? v.cars[0] : null; const trim = scd ? null : (v.trims.find((t) => t.mjsid === mjsid) || v.trims[0]); const run = async (kind, value) => { setBusy(kind === "group" ? `Собираю группу «${value}»…` : "Ищу…"); setErr(""); setPage(0); try { let r; if (scd) r = await api(`/api/scd/v/${car.id}/search`, { body: { q: value } }); else if (kind === "group") r = await api("/api/group", { body: { vin, mjsid, group: value } }); else r = await api("/api/search", { body: { vin, mjsid, q: value } }); setRes(r); if (!scd) setLastSearch({ vin, mjsid, q: value, res: r }); } catch (x) { setErr(x.message); } setBusy(""); }; const pickLive = (it, items) => { // деталь из списка → полная карточка. Показываем ВСЕ варианты этой позиции (разные заводы, разная цена), // иначе теряем альтернативы: у F083502080SB и F183502080 одна позиция, но разные производители. setGrp(""); setPage(0); const same = (items || []).filter((x) => x.key === it.key && (x.name || "") === (it.name || "") && (!it.full.ref || !x.full.ref || x.full.ref === it.full.ref)); const list = [it, ...same.filter((x) => x !== it)].map((x) => x.full); const r = { mode: "pick", q: it.word, keys: [it.key], suggestions: [], parts: list }; setRes(r); if (!scd) setLastSearch({ vin, mjsid, q: it.word, res: r }); api("/api/live/pick", { body: { word: it.word, pn_id: it.pn_id, key: it.key } }).catch(() => {}); }; const pickGroup = (g) => { setGrp(g); if (!g) return; if (scd) { setRes(null); return; } // SHICHEDAO платный — показываем слова группы, каждое = 1 вызов setQ(""); run("group", g); }; const onScheme = (p) => go(`/vin/${vin}/scheme?mjsid=${mjsid}&image=${encodeURIComponent(p.image)}&prefix=${encodeURIComponent(p.prefix || v.prefix || "")}&focus=${encodeURIComponent(p.pn_id)}`); const path = scd ? (car.path || "").split(">").map((x) => x.trim()).filter(Boolean) : null; const crumbs = scd ? [{ t: "Главная", href: "#/" }, ...path.slice(0, 3).map((t) => ({ t }))] // поколение в крошки не ставим: это внутренняя китайская метка поставщика (探索06 2023-) : [{ t: "Главная", href: "#/" }, { t: v.brand_ru || v.brand }, { t: v.alias || `${v.untranslated || v.sys} — без перевода` }]; const grpWords = (groups.find((g) => g.group === grp) || {}).words || []; return (
{/* Вердикт может родиться в том же запросе, что и первый клик по группе: тогда подстановка приходит в ОТВЕТЕ, а карточка машины про неё ещё не знает. */}
{ e.preventDefault(); if (q.trim()) run("q", q.trim()); }}>
{scd && grp && (
У SHICHEDAO каждый поиск платный — выберите деталь группы: {grpWords.map((w) => )}
)} {busy &&
{busy}
} {err &&
{err}
} {!res && !busy && (
{groups.map((g) => ( ))}
)}
{scd ? (res && (res.mode === "no_dict" ?
Слова «{res.q}» нет в словаре. Запрос записан.
: )) : }
{picker && setPicker(false)} onPick={(t) => { ssSet(`vc_trim_${vin}`, t.mjsid); setMjsid(t.mjsid); setPicker(false); setRes(null); setGrp(""); }} />}
); } // ------------------------------------------------------------ 3. подбор // Условия нет — на карточке молчим: заголовок группы уже сказал «по VIN не определяется». // Плашки тут быть не должно, её принимают за кнопку. function Condition({ p }) { if (!p.condition) return null; return {p.condition}; } const BADGE = { exact: { cls: "exact", text: "НОМЕР ЭТОЙ МАШИНЫ", hint: "API явно подтвердил номер для этой комплектации (containFlag = 0)" }, analog: { cls: "analog", text: "АНАЛОГ ПО ПОЗИЦИИ — ПРОВЕРЬТЕ", hint: "Номер этой машине не принадлежит: система подставила деталь той же позиции (containFlag = 1). Перед заказом сверьте." }, unknown: { cls: "unknown", text: "применимость не подтверждена поставщиком", hint: "API не сообщил, родной ли это номер для комплектации (поле containFlag пустое). Точность не гарантирована." }, }; function mergeParts(parts) { // одинаковый номер с одинаковым примечанием склеиваем (API шлёт дубли с заменой и без) const m = new Map(); for (const p of parts) { const k = `${p.key}|${p.pn_id}|${p.comment || ""}`; if (!m.has(k)) { m.set(k, { ...p, substitutes: p.substitute ? [p.substitute] : [], refs: p.ref ? [p.ref] : [] }); continue; } const x = m.get(k); if (p.substitute && !x.substitutes.includes(p.substitute)) x.substitutes.push(p.substitute); if (p.ref && !x.refs.includes(p.ref)) x.refs.push(p.ref); if (x.badge !== p.badge) x.badge = x.badge === "analog" || p.badge === "analog" ? "analog" : "unknown"; } // группы: один ключ + одно название, разные номера → варианты const groups = new Map(); for (const p of m.values()) { const g = `${p.key}|${p.stdPartName}`; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(p); } return [...groups.values()]; } // Примечание одной строкой: перевод и наши пояснения нередко говорят одно и то же // («Корпус чёрный» шло и заголовком, и пунктом списка). Оставляем непустое и неповторяющееся. // На карточке показываем ТОЛЬКО то, что разобрано правилами. Машинный перевод примечания // бывает бессмысленным («7FP/世钟» → «7FP/мировой колокол»), и выдавать его за смысл нельзя: // оригинал и перевод лежат под «подробнее», там видно, что это машина, а не факт. function noteLine(p) { const norm = (s) => String(s || "").toLowerCase().replace(/[.\s]+$/, "").trim(); const parts = []; for (const s of p.comment_hints || []) { if (!s) continue; if (!parts.some((x) => norm(x) === norm(s))) parts.push(s); } return parts.join(" · "); } function PartBody({ p, compact, more }) { return ( <> {/* Поиск поставщика нечёткий: оригинал рядом с переводом, чтобы было видно, если вернулось не то */}
{p.stdPartName_ru ? {p.stdPartName_ru} : {p.stdPartName} без перевода} {p.mfr && исполнение {p.mfr.name_ru || p.mfr.name}{p.mfr.code ? ` (${p.mfr.code})` : ""}} {p.stdPartName_src === "llm" && машинный перевод}
{more && p.stdPartName_ru && (
в каталоге поставщика: {p.stdPartName}
)} {more && p.srcPartName && p.srcPartName !== p.stdPartName && p.srcPartName_ru !== p.stdPartName_ru && (
заводское: {p.srcPartName}{p.srcPartName_ru ? ` — ${p.srcPartName_ru}` : ""}
)} {(noteLine(p) || (more && p.comment)) && (
{noteLine(p) && {noteLine(p)}} {more && p.comment && (
{p.comment} {p.comment_ru && <> — {p.comment_ru}} {p.comment_src === "llm" && · машинный перевод}
)}
)} {p.substitutes && p.substitutes.length > 0 && (
Замена в базе: {p.substitutes.map((s) => {s})}
)} {!compact && p.reqPartNumber && p.reqPartNumber !== p.partNumber && (
Искали номер {p.reqPartNumber}
)} ); } // Миниатюра грузится лениво и отдельным запросом: список должен появиться сразу, не дожидаясь // картинок. Часть схем поставщик не отдаёт вовсе — тогда картинки просто нет, а кнопка остаётся: // битый значок хуже, чем его отсутствие. function Thumb({ p, onScheme }) { const [bad, setBad] = useState(false); return ( ); } function PriceBox({ p, onScheme, more, setMore }) { // сервис подбора артикулов: юани и китайский прячем под «подробнее», данные собираем как прежде const price = parseFloat(p.partPrice); return (
кол-во в позиции: {p.qty || "—"}
{p.image && } {more && (
{price > 0 ? <>заводская цена: {price.toFixed(0)} ¥ : "цены в прайсе нет"} {p.partCode &&
код детали: {p.partCode}
} {p.ref &&
позиция на схеме: {p.ref}
}
)}
); } function PartCard({ p, onScheme, vin, hideBadge }) { const [open, setOpen] = useState(null); // что открылось нажатием: номер и замены const [more, setMore] = useState(false); // «подробнее» одно на карточку: и китайский, и юани const pp = open ? { ...p, partNumber: open.part_number, substitutes: open.substitutes || [] } : p; const b = BADGE[p.badge]; return (
{!hideBadge && {b.text}}
{p.own === false &&
На вашей комплектации этот номер не встретился. Годится ли он вам, поставщик не сообщает.
} {p.badge === "analog" &&
{b.hint}
}
); } function variantReason(list) { // почему номеров несколько: артикул назначает автопроизводитель, это РАЗНЫЕ исполнения, не взаимозамена if (list.some((p) => p.date_split)) return { head: "зависят от даты сборки машины", tail: "Точная дата сборки по VIN не приходит — только год. Проверьте по ПТС, табличке в проёме двери или по резьбе колёсного болта." }; const mfrs = [...new Set(list.map((p) => p.mfr && (p.mfr.name_ru || p.mfr.name)).filter(Boolean))]; if (mfrs.length > 1) return { head: `разные исполнения: ${mfrs.join(" и ")}`, tail: "Это не взаимозаменяемые аналоги: номер назначает автопроизводитель, исполнения отличаются. Уточните по машине, какое стоит." }; return { head: "по VIN не определяется, какое из них стоит", tail: "Поставщик даёт несколько номеров на одну позицию и не указывает, чем они различаются. Сверяйте по снятой детали." }; } function VariantRow({ p, onScheme, hideBadge }) { const [open, setOpen] = useState(null); const [more, setMore] = useState(false); const pp = open ? { ...p, partNumber: open.part_number, substitutes: open.substitutes || [] } : p; const b = BADGE[p.badge]; return (
{!hideBadge && {b.text}}
); } function Variants({ list, onScheme, vin }) { const reason = variantReason(list); // Одна служебная пометка на виду: если применимость не подтверждена у ВСЕХ вариантов, // говорим это один раз в заголовке, а не на каждой карточке. const allUnknown = list.every((p) => p.badge === "unknown"); return (
{count(list.length, "вариант", "варианта", "вариантов")} «{list[0].stdPartName_ru || list[0].stdPartName}» — {reason.head}
{allUnknown &&
применимость ни одного из них поставщик не подтвердил
}
{list.map((p) => ( ))}
{reason.tail}
); } function Parts({ vin, route, setCtx, lastSearch, setLastSearch }) { const mjsid = route.q.mjsid, rim = route.q.rim; const [v, setV] = useState(null); const [q, setQ] = useState(lastSearch && lastSearch.vin === vin && lastSearch.mjsid === mjsid ? lastSearch.q : ""); const [res, setRes] = useState(lastSearch && lastSearch.vin === vin && lastSearch.mjsid === mjsid ? lastSearch.res : null); const [busy, setBusy] = useState(false), [err, setErr] = useState(""); useEffect(() => { api(`/api/vin/${vin}?provider=mattrio`).then(setV).catch((x) => setErr(x.message)); }, [vin]); const trim = v && v.trims && v.trims.find((t) => t.mjsid === mjsid); useEffect(() => { if (v) setCtx(`${carTitle(v)} · ${v.displacement || ""} · ${v.vin_year || ""}${trim && trim.frontTyre ? " · " + trim.frontTyre : ""}`); }, [v, trim]); const run = async (text) => { const qq = (text != null ? text : q).trim(); if (!qq) return; setQ(qq); setBusy(true); setErr(""); try { const r = await api("/api/search", { body: { vin, mjsid, q: qq } }); setRes(r); setLastSearch({ vin, mjsid, q: qq, res: r }); } catch (x) { setErr(x.message); } setBusy(false); }; const groups = useMemo(() => (res ? mergeParts(res.parts) : []), [res]); const onScheme = (p) => { go(`/vin/${vin}/scheme?mjsid=${mjsid}&image=${encodeURIComponent(p.image)}&prefix=${encodeURIComponent(p.prefix || (v && v.prefix) || "")}&focus=${encodeURIComponent(p.pn_id)}${rim ? `&rim=${rim}` : ""}`); }; if (!mjsid) return
Не выбрана комплектация. Выбрать
; return (
{trim ? <>Комплектация: {trim.salesName_ru || trim.salesName} · {trim.drive_ru} · {trim.frontTyre} : {mjsid}} {" · "}сменить
в работе Каталог по узлам
{/* Группа поставщика держит несколько российских моделей (揽月, 瑞虎8_PRO). Значит, каталог привязан к группе, а не к конкретной машине, и отличия комплектации он может не знать. */} {v && v.models_many && (
Каталог может не совпадать с машиной: у этой группы поставщика несколько российских моделей ({v.models.join(", ")}). Коробку, привод и кузов сверяйте по самой машине.
)}
{ e.preventDefault(); run(); }}> run(s)} autoFocus placeholder="колодки передние · фильтр масляный · стойка стабилизатора" />
Поставщик ищет только по-китайски и только по названию: русское слово переводится по словарю. По номеру детали каталог не ищет — даже по своему собственному.
{err &&
{err}
} {res && res.mode === "no_dict" && (
Слова «{res.q}» нет в словаре — поставщику искать нечего. {res.suggestions.length > 0 ? "Возможно, вы имели в виду:" : "Добавить слово может Вадим в админке → Словарь."} {res.suggestions.length > 0 && (
{res.suggestions.map((s) => ( ))}
)}
)} {res && res.substitute && } {res && res.no_parts && } {res && res.mode !== "no_dict" && ( <>
{res.mode === "oe" ? "Поиск по номеру" : res.mode === "zh" ? "Поиск по-китайски" : `Запрос к поставщику: ${res.keys.join(", ")}`} найдено {count(res.parts.length, "деталь", "детали", "деталей")} номер этой машины аналог по позиции — проверьте привязка не указана
{res.parts.length === 0 && (
Поставщик ничего не нашёл.
{res.per_key.map((k) => `${k.key}: ${k.code === "402" ? "нет результата" : k.code}`).join(" · ")} {res.mode === "oe" && " · Номер отсутствует в базе поставщика или не относится к этой комплектации."}
{!res.no_parts && (
Возможно, у поставщика нет именно этой группы — проверьте соседнюю.
)}
)} {/* «Варианты» — только развилка по дате сборки. Одинаковое stdPartName у разных деталей (передний и задний сайлентблок рычага) — это разные детали, а не выбор. */} {groups.map((list) => list.length > 1 && list.some((p) => p.date_split) ? : list.map((p) => ))} )}
); } // ------------------------------------------------------------ 4. схема function Scheme({ vin, route, setCtx, lastSearch }) { const { mjsid, image, prefix, focus, rim } = route.q; const [s, setS] = useState(null), [err, setErr] = useState(""), [full, setFull] = useState(false); useEffect(() => { setS(null); setErr(""); api(`/api/scheme?${new URLSearchParams({ image, prefix: prefix || "", vin, mjsid })}`).then(setS).catch((x) => setErr(x.message)); }, [image]); useEffect(() => { setCtx("Схема узла"); }, []); useEffect(() => { const f = (e) => { if (e.key === "Escape") setFull(false); }; if (full) { addEventListener("keydown", f); return () => removeEventListener("keydown", f); } }, [full]); // пока getPartByImage отвечает 403 — список из результатов поиска с той же схемой const fromSearch = useMemo(() => { if (!lastSearch || lastSearch.vin !== vin) return []; const seen = new Set(); return lastSearch.res.parts.filter((p) => p.image === image && !seen.has(p.pn_id + p.ref) && seen.add(p.pn_id + p.ref)); }, [lastSearch, image]); const list = s && s.parts_status === "ok" ? s.parts : fromSearch; const back = `#/vin/${vin}/parts?mjsid=${mjsid}${rim ? `&rim=${rim}` : ""}`; return (
{err &&
{err}
} {!s && !err &&
Загружаю схему у поставщика…
} {s && (
{s.width && `${s.width}×${s.height}`} · нажмите, чтобы открыть во весь экран в работе Точки на схеме
Схема сохранена у нас — ссылки поставщика временные. Кликабельных позиций пока нет: метод деталей схемы отвечает ошибкой ({s.parts_code || "нет ответа"}), вопрос у поставщика. Номера позиций — в списке справа.
Детали на схеме
{s.parts_status !== "ok" && (
Полный список деталей узла поставщик пока не отдаёт. Показаны найденные поиском детали с этой схемы.
)} {!list.length &&
Нет данных
}
{list.map((p, i) => (
{p.ref || "–"}
{p.stdPartName_ru || перевода нет} {p.stdPartName}
{p.comment_ru &&
{p.comment_ru}
} {BADGE[p.badge].text}
{parseFloat(p.partPrice) > 0 ? `${parseFloat(p.partPrice).toFixed(0)} ¥` : ""}
))}
{list.some((p) => p.partNumber) && ( )}
)} {full && s && (
setFull(false)}> Схема узла
)}
); } // ------------------------------------------------------------ 5. админка const ACTIONS = { vin: "VIN", search: "поиск", scheme: "схема", login: "вход", login_fail: "неверный ПИН" }; function AdminLog({ users }) { const [f, setF] = useState({ who_f: "", errors: 0 }); const [data, setData] = useState(null), [offset, setOffset] = useState(0), [raw, setRaw] = useState(null); const qs = new URLSearchParams({ who_f: f.who_f, errors: f.errors, limit: 100, offset }); useEffect(() => { api(`/api/admin/log?${qs}`).then(setData); }, [f, offset]); const openRaw = async (id) => setRaw(await api(`/api/admin/raw/${id}`)); const set = (patch) => { setOffset(0); setF({ ...f, ...patch }); }; return (
Журнал запросов {data && · {data.total}}
{Object.entries(users).map(([k, n]) => )}
{data && data.rows.map((r) => { const ids = (r.detail && r.detail.raw_ids) || (r.raw_id ? [r.raw_id] : []); return ( ); })}
ВремяКтоДействиеЗапросОтветКэшКвотамсСырое
{fmtTime(r.ts)} {users[r.who] || r.who} {ACTIONS[r.action] || r.action} {r.query}{r.detail && r.detail.found != null && · {r.detail.found} шт} {r.code || (r.error ? "ошибка" : "ок")} {r.cache_hit ? "да" : ""} {r.frequency != null ? r.frequency : "—"} {r.duration_ms != null ? r.duration_ms : ""} {ids.map((id) => { e.preventDefault(); openRaw(id); }} style={{ marginRight: 6 }}>#{id})}
Выгрузить в CSV {offset > 0 && } {data && offset + 100 < data.total && }
{raw && (
setRaw(null)}>
e.stopPropagation()}>
Сырой ответ #{raw.id} · {raw.method} · {raw.who} · http {raw.http} · code {raw.code} · квота {raw.frequency != null ? raw.frequency : "—"}
Параметры
{JSON.stringify(raw.params, null, 2)}
Ответ как есть (userId вырезан)
{typeof raw.body === "string" ? raw.body : JSON.stringify(raw.body, null, 2)}
)}
); } const TABLE_DEF = { dict: { title: "Словарь поиска (рус → кит)", cols: [["ru", "Русское слово"], ["zh", "Китайский ключ"], ["grp", "Группа"]], pk: "id", extra: (r) => r.hits == null ? не проверено : r.hits === 0 ? 0 деталей : {r.hits} шт }, tr: { title: "Переводы (кит → рус)", cols: [["zh", "Китайский"], ["ru", "Русский"]], pk: "zh", extra: (r) => {r.source === "llm" ? "машинный" : "ручной"} }, wmi: { title: "Префиксы VIN местной сборки", cols: [["wmi", "Префикс (3 знака)"], ["plant", "Завод"], ["brands", "Марки"], ["note", "Что сказать менеджеру"], ["kind", "local / china"], ["scd_manufacturer", "SHICHEDAO: завод"], ["scd_brand", "бренд"], ["scd_filter", "фильтр моделей"]], pk: "wmi" }, route: { title: "Маршрут по префиксу VIN (самый длинный совпавший; остальное — MATTRIO)", cols: [["prefix", "Префикс"], ["provider", "mattrio / shichedao"], ["note", "Что это"]], pk: "prefix" }, donor: { title: "Префикс VIN местной сборки → модель и китайский VIN-донор (самый длинный совпавший префикс)", cols: [["prefix", "Префикс VIN"], ["brand", "Марка"], ["model", "Модель"], ["donor_vin", "Китайский VIN той же модели"], ["note", "Откуда известно"]], pk: "prefix" }, alias: { title: "Русские названия моделей (префикс — когда одно имя значит разные машины)", cols: [["brand", "Марка (кит.)"], ["sys", "Модель у поставщика"], ["ru_name", "Название для менеджера"], ["wmi", "Префикс VIN"]], pk: "id" }, confirm: { title: "Подтверждённые машины: VIN → модель (показывается без пометки «предположение»)", cols: [["vin", "VIN"], ["model", "Модель"], ["note", "Примечание"]], pk: "vin" }, }; function AdminTable({ name }) { const def = TABLE_DEF[name]; const [rows, setRows] = useState(null), [edit, setEdit] = useState(null), [flt, setFlt] = useState(""), [err, setErr] = useState(""); const load = () => api(`/api/admin/table/${name}`).then(setRows); useEffect(() => { setRows(null); setEdit(null); load(); }, [name]); const save = async () => { setErr(""); try { await api(`/api/admin/table/${name}`, { body: edit }); setEdit(null); load(); } catch (x) { setErr(x.message); } }; const del = async (r) => { if (confirm("Удалить строку?")) { await api(`/api/admin/table/${name}/${encodeURIComponent(r[def.pk])}`, { method: "DELETE" }); load(); } }; const shown = (rows || []).filter((r) => !flt || def.cols.some(([c]) => String(r[c] || "").toLowerCase().includes(flt.toLowerCase()))); return (
{def.title} {rows && · {rows.length}}
setFlt(e.target.value)} />
{edit && (
{def.cols.map(([c, t]) => ( setEdit({ ...edit, [c]: e.target.value })} /> ))} {err && {err}}
)}
{def.cols.map(([c, t]) => )}{def.extra && {shown.map((r) => ( {def.cols.map(([c]) => )} {def.extra && } ))}
{t}}
{r[c]}{def.extra(r)} { e.preventDefault(); setEdit({ ...r, _existing: true }); }}>правка{" · "} { e.preventDefault(); del(r); }} style={{ color: "var(--err)" }}>удалить
); } function FallbackStats() { const [rows, setRows] = useState(null); useEffect(() => { api("/api/admin/fallback").then(setRows).catch(() => setRows([])); }, []); return (
Запасной путь
Основной поставщик ответил «не найдено» — сервис спросил второго. Если префикс постоянно уходит ко второму и тот находит — маршрут стоит поменять насовсем.
{rows && rows.map((r, i) => ( ))} {rows && !rows.length && }
ПрефиксОсновной → запаснойРазРазных VINЗапасной нашёл
{r.prefix}{PROV[r.src] || r.src} → {PROV[r.dst] || r.dst} {r.n}{r.vins}{r.found} из {r.n}
Пока не срабатывал
); } function DictMiss() { const [rows, setRows] = useState(null), [add, setAdd] = useState(null), [err, setErr] = useState(""); const load = () => api("/api/admin/dict_miss").then(setRows); useEffect(() => { load(); }, []); const save = async () => { setErr(""); try { await api("/api/admin/table/dict", { body: { ru: add.ru, zh: add.zh, grp: add.grp } }); await api(`/api/admin/dict_miss/${encodeURIComponent(add.q)}`, { method: "DELETE" }); DICT_CACHE = null; setAdd(null); load(); } catch (x) { setErr(x.message); } }; const del = async (r) => { await api(`/api/admin/dict_miss/${encodeURIComponent(r.q)}`, { method: "DELETE" }); load(); }; return (
Не нашлось в словаре {rows && · {rows.length}}
Что люди искали, а словаря не хватило. Частые — сверху: пополняйте словарь по ним. Китайский ключ проверяйте поиском по-китайски на реальной машине — поиск поставщика нечёткий.
{add && (
setAdd({ ...add, ru: e.target.value })} placeholder="русское слово" /> setAdd({ ...add, zh: e.target.value })} placeholder="китайский ключ" autoFocus /> setAdd({ ...add, grp: e.target.value })} placeholder="группа" /> {err && {err}}
)}
{rows && rows.map((r) => ( ))} {rows && !rows.length && }
ЗапросРазПоследнийКтоМы подсказали
{r.q_last}{r.cnt}{fmtTime(r.last_at)}{r.last_who} {(r.suggestions || []).join(", ")} { e.preventDefault(); setAdd({ q: r.q, ru: r.q, zh: "", grp: "" }); }}>в словарь{" · "} { e.preventDefault(); del(r); }}>убрать
Пока пусто
); } function DictCheck() { const [vins, setVins] = useState([]), [vin, setVin] = useState(""), [mjsid, setMjsid] = useState(""), [v, setV] = useState(null); const [busy, setBusy] = useState(false), [res, setRes] = useState(null), [err, setErr] = useState(""); useEffect(() => { api("/api/recent").then((r) => setVins(r.filter((x) => x.found))); }, []); useEffect(() => { if (vin) api(`/api/vin/${vin}`).then(setV); }, [vin]); const run = async () => { setBusy(true); setErr(""); try { setRes(await api("/api/admin/dict/check", { body: { vin, mjsid } })); } catch (x) { setErr(x.message); } setBusy(false); }; return (
Проверка словаря
Прогоняет все китайские ключи словаря по одной машине и отмечает, сколько деталей нашёл каждый. Квоту не тратит (поиск бесплатный), идёт несколько минут.
{err &&
{err}
} {res &&
Проверено ключей: {res.checked}. Без результата: {res.zero.length ? res.zero.join(", ") : "нет"}.
}
); } function Admin({ me, setCtx, onQuota }) { const [s, setS] = useState(null), [tab, setTab] = useState("log"), [forget, setForget] = useState(""); useEffect(() => { setCtx("админка"); api("/api/admin/summary").then(setS); onQuota(); }, []); if (!me.admin) return
Только для Вадима.
; if (!s) return
загрузка…
; const doForget = async () => { if (!forget || !confirm(`Стереть ${forget} из памяти? Следующий ввод этого VIN спишет 1 запрос квоты.`)) return; await api(`/api/admin/vin/${forget.trim().toUpperCase()}`, { method: "DELETE" }); setForget(""); }; return (
Остаток квоты
{s.quota.left != null ? s.quota.left : "—"}
по последнему ответу поставщика{s.quota.updated_at ? `, ${fmtTime(s.quota.updated_at)}` : ""}
VIN определено
{s.vins_cached}
платных запросов из сервиса: {s.vin_calls}
Действий в журнале
{s.requests}
из кэша {s.cache_hits} · вызовов API {s.api_calls}
{s.scd && s.scd.enabled && (
SHICHEDAO: вызовы
{s.scd.calls}
сэкономлено кэшем {s.scd.saved} {s.scd.calls + s.scd.saved > 0 ? ` · ${Math.round(s.scd.saved / (s.scd.calls + s.scd.saved) * 100)}%` : ""}
{s.scd.last_quota_raw &&
последний отказ «пакет исчерпан»: сырой ответ #{s.scd.last_quota_raw}
}
)}
Ошибок
{s.errors}
средний ответ API {s.avg_ms != null ? `${(s.avg_ms / 1000).toFixed(1)} с` : "—"}
{[["log", "Журнал"], ["dict", "Словарь"], ["tr", "Переводы"], ["alias", "Модели"], ["wmi", "Префиксы"], ["route", "Маршруты"], ["donor", "Доноры"], ["confirm", "Подтверждения"], ["miss", "Не нашлось в словаре"], ["check", "Проверка словаря"], ["vin", "Память VIN"]].map(([k, t]) => )}
{tab === "log" && } {tab === "route" && } {["dict", "tr", "alias", "wmi", "route", "donor", "confirm"].includes(tab) && } {tab === "check" && } {tab === "miss" && } {tab === "vin" && (
Стереть VIN из памяти
Ответы по VIN хранятся навсегда, чтобы не тратить квоту. Стирать — только если ответ поставщика был ошибочным и нужно спросить заново.
setForget(e.target.value)} />
)}
); } // ------------------------------------------------------------ тариф и оплата const RUB = (n) => `${n}`.replace(/\B(?=(\d{3})+(?!\d))/g, " ") + " ₽"; const ORDER_KEY = "vc_last_order"; function PayResult({ orderId, onDone }) { const [st, setSt] = useState(null), [err, setErr] = useState(""); useEffect(() => { // возврат из кассы сам по себе ничего не значит: деньги подтверждает вебхук, // и между «вернулся» и «зачислено» бывает пауза. Поэтому спрашиваем заказ. let stop = false, tries = 0; const ask = () => api(`/api/billing/order/${orderId}`).then((d) => { if (stop) return; setSt(d); if (d.status === "succeeded") { localStorage.removeItem(ORDER_KEY); onDone(); return; } if (d.status === "pending" && ++tries < 10) setTimeout(ask, 3000); }).catch((e) => setErr(e.message)); ask(); return () => { stop = true; }; }, [orderId]); if (err) return
Не удалось узнать судьбу платежа: {err}
; if (!st) return
Проверяем платёж…
; if (st.status === "succeeded") return
Оплачено. {RUB(st.amount_rub)} зачислены, лимиты уже обновлены.
; if (st.status === "canceled") return
Платёж отменён. Деньги не списаны, можно начать заново.
; return
Ждём подтверждения кассы. Обычно это минута. Лимиты обновятся сами, страницу можно закрыть.
; } function Billing({ setCtx, onPaid }) { const [d, setD] = useState(null), [cab, setCab] = useState(null); const [email, setEmail] = useState(""); const [busy, setBusy] = useState(""), [err, setErr] = useState(""); const [order, setOrder] = useState(() => localStorage.getItem(ORDER_KEY) || ""); useEffect(() => { setCtx("тариф и оплата"); api("/api/billing/plans").then(setD).catch((e) => setErr(e.message)); api("/api/cabinet").then((c) => { setCab(c); if (c.email) setEmail(c.email); }); }, []); if (err) return
{err}
; if (!d) return
Загружаем…
; const u = (cab && cab.usage) || {}; const ok = /^[^@\s]+@[^@\s.]+\.[^@\s]{2,}$/.test(email.trim()); const pay = async (kind, item, qty) => { setBusy(`${kind}:${item}`); setErr(""); try { const r = await api("/api/billing/pay", { body: { kind, item, qty, email: email.trim() } }); localStorage.setItem(ORDER_KEY, r.order_id); location.href = r.confirmation_url; } catch (x) { setErr(x.message); setBusy(""); } }; return (

Тариф и оплата

{order && { setOrder(""); onPaid(); api("/api/cabinet").then(setCab); }} />} {u.requests_limit >= 0 && (
Остаток на {u.period}
{Math.max(0, u.requests_limit - u.requests_used)} из {u.requests_limit} номеров
{Math.max(0, u.vin_limit - u.vin_used)} из {u.vin_limit} машин
тариф «{u.plan_name}»
)} {!d.enabled &&
Оплата скоро появится. Тарифы ниже — те, что будут действовать; пока не подключили кассу, доступ продлеваем вручную — напишите нам.
} {d.enabled && (
setEmail(e.target.value)} style={{ maxWidth: 420 }} />
Пришлём чек. Без почты оплатить нельзя — этого требует закон.
)}

Тарифы

{d.plans.filter((p) => p.price > 0).map((p) => (
{p.name}
{RUB(p.price)} / мес
  • {p.requests} {plural(p.requests, "раскрытие", "раскрытия", "раскрытий")} номеров
  • {p.vin} {plural(p.vin, "машина", "машины", "машин")} по VIN
))}

Докупка сверх тарифа

{d.packs.map((p) => (
{p.name}
{RUB(p.price)} / {p.unit} {plural(p.unit, "штука", "штуки", "штук")}
))}
{d.public_pages &&

Условия — в публичной оферте.

}
); } // ------------------------------------------------------------ приложение function App() { const [me, setMe] = useState(undefined); const [ctx, setCtx] = useState(""); const [lastSearch, setLastSearch] = useState(null); const route = useRoute(); const refreshMe = useCallback(() => api("/api/me").then(setMe).catch(() => setMe(null)), []); useEffect(() => { refreshMe(); const f = () => setMe(null); addEventListener("vc-logout", f); return () => removeEventListener("vc-logout", f); }, []); useEffect(() => { setCtx(""); }, [route.parts.join("/")]); STAFF = !!(me && me.staff); if (me === undefined) return null; const linkMatch = location.hash.match(/^#\/in\/([\w-]+)(?:\/([\w-]+))?/) || []; if (!me && linkMatch[1]) return ; if (!me) return ; const logout = async () => { if (confirm("Выйти?")) { await api("/api/logout", { body: {} }); setMe(null); } }; const [a, vin, sub] = route.parts; let page; if (a === "vin" && vin && sub === "parts") page = ; else if (a === "vin" && vin && sub === "scheme") page = ; else if (a === "vin" && vin) page = ; else if (a === "admin") page = ; else if (a === "cabinet") page = ; else if (a === "scd-models" && vin) page = ; else if (a === "scd-brands" && vin) page = ; else if (a === "car" && vin) page = ; else if (a === "scd" && vin && sub === "d") page = ; else if (a === "scd" && vin) page = ; else page = ; return (<>{page}); } ReactDOM.createRoot(document.getElementById("root")).render();