Cat / index.html
Starchik1's picture
Update index.html
4a1fc14 verified
Raw
History Blame Contribute Delete
77.2 kB
<!DOCTYPE html><html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Подбор запчастей</title>
<style>
:root {
--bg: #0b1020;
--panel: rgba(17, 24, 39, 0.86);
--panel-2: rgba(15, 23, 42, 0.9);
--card: rgba(255, 255, 255, 0.06);
--card-2: rgba(255, 255, 255, 0.09);
--text: #e5eefb;
--muted: #9fb0cc;
--line: rgba(255, 255, 255, 0.1);
--accent: #6d8dff;
--accent-2: #47d7ac;
--danger: #ff6b7a;
--warn: #ffcb66;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
--radius: 22px;
--radius-sm: 14px;
--radius-xs: 10px;
}* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(circle at 15% 10%, rgba(109, 141, 255, 0.22), transparent 25%),
radial-gradient(circle at 85% 5%, rgba(71, 215, 172, 0.16), transparent 20%),
radial-gradient(circle at 50% 100%, rgba(255, 203, 102, 0.09), transparent 28%),
linear-gradient(180deg, #060913 0%, #0a1020 45%, #070b15 100%);
}
.app {
max-width: 1440px;
margin: 0 auto;
padding: 20px;
}
.topbar {
display: flex;
gap: 14px;
align-items: stretch;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
}
.brand {
flex: 1 1 320px;
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04));
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.brand h1 {
margin: 0 0 6px;
font-size: clamp(24px, 4vw, 38px);
line-height: 1.05;
letter-spacing: -0.03em;
}
.brand p {
margin: 0;
color: var(--muted);
max-width: 80ch;
line-height: 1.45;
font-size: 14px;
}
.status {
flex: 0 1 360px;
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04));
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow);
display: grid;
gap: 10px;
backdrop-filter: blur(14px);
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.pill {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--line);
border-radius: 16px;
padding: 10px 12px;
min-height: 60px;
}
.pill .label {
font-size: 11px;
color: var(--muted);
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pill .value {
font-size: 14px;
font-weight: 700;
line-height: 1.25;
}
.layout {
display: grid;
grid-template-columns: 340px 1fr 360px;
gap: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.layout { grid-template-columns: 1fr; }
.status { flex-basis: 100%; }
}
.panel {
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,0.03);
}
.panel-head h2 {
margin: 0;
font-size: 18px;
letter-spacing: -0.02em;
}
.panel-head small {
color: var(--muted);
}
.panel-body {
padding: 16px 18px 18px;
}
.stack { display: grid; gap: 12px; }
.grid-2 { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
.grid-3 { display: grid; gap: 12px; grid-template-columns: repeat(3, 1fr); }
@media (max-width: 720px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: 1fr; }
}
.field {
display: grid;
gap: 7px;
}
.field label {
font-size: 12px;
color: var(--muted);
}
input, select, textarea, button {
font: inherit;
}
input[type="text"], input[type="search"], input[type="password"], select, textarea {
width: 100%;
background: rgba(8, 12, 22, 0.75);
color: var(--text);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 14px;
padding: 12px 14px;
outline: none;
transition: 0.18s ease;
}
textarea { resize: vertical; min-height: 88px; }
input:focus, select:focus, textarea:focus {
border-color: rgba(109, 141, 255, 0.7);
box-shadow: 0 0 0 4px rgba(109, 141, 255, 0.15);
}
.btn-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 14px;
padding: 12px 14px;
color: #fff;
background: linear-gradient(135deg, var(--accent), #8a6dff);
cursor: pointer;
font-weight: 700;
letter-spacing: 0.01em;
transition: transform 0.16s ease, filter 0.16s ease, opacity 0.16s ease;
}
button:hover { transform: translateY(-1px); filter: brightness(1.05); }
button:active { transform: translateY(0); opacity: 0.92; }
button.secondary { background: rgba(255,255,255,0.08); border: 1px solid var(--line); }
button.success { background: linear-gradient(135deg, var(--accent-2), #2ea0ff); }
button.danger { background: linear-gradient(135deg, var(--danger), #d14be2); }
button.ghost { background: transparent; border: 1px solid var(--line); color: var(--text); }
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.vin-ok { color: #7ff0b0; }
.vin-bad { color: #ff8d99; }
.car-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.07);
border: 1px solid var(--line);
color: var(--text);
font-size: 13px;
cursor: pointer;
user-select: none;
}
.chip[data-active="true"] {
background: rgba(109, 141, 255, 0.2);
border-color: rgba(109, 141, 255, 0.7);
}
.list {
display: grid;
gap: 10px;
}
.part-card {
background: rgba(255,255,255,0.06);
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px;
display: grid;
gap: 10px;
}
.part-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.part-name {
margin: 0;
font-size: 16px;
line-height: 1.2;
}
.part-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 6px 9px;
border-radius: 999px;
background: rgba(255,255,255,0.07);
border: 1px solid var(--line);
color: var(--muted);
}
.tag strong { color: var(--text); font-weight: 700; }
.price {
font-weight: 800;
font-size: 18px;
white-space: nowrap;
}
.part-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.part-actions button { padding: 9px 11px; font-size: 13px; border-radius: 12px; }
.results-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 14px;
flex-wrap: wrap;
}
.results-head h3 {
margin: 0;
font-size: 18px;
}
.results-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 13px;
}
.right-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 0 18px 16px;
}
.tab {
border: 1px solid var(--line);
background: rgba(255,255,255,0.05);
color: var(--text);
border-radius: 999px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.tab.active {
background: rgba(109, 141, 255, 0.22);
border-color: rgba(109, 141, 255, 0.7);
}
.chat {
display: grid;
gap: 10px;
}
.chat-log {
display: grid;
gap: 10px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.bubble {
max-width: 100%;
border-radius: 16px;
padding: 12px 13px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.06);
line-height: 1.45;
font-size: 14px;
white-space: pre-wrap;
}
.bubble.me {
background: rgba(109, 141, 255, 0.15);
border-color: rgba(109, 141, 255, 0.35);
}
.bubble.ai {
background: rgba(71, 215, 172, 0.12);
border-color: rgba(71, 215, 172, 0.28);
}
.tiny {
font-size: 12px;
color: var(--muted);
}
.divider {
height: 1px;
background: var(--line);
margin: 8px 0;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 100;
}
.modal-backdrop.show { display: flex; }
.modal {
width: min(920px, 100%);
max-height: 90vh;
overflow: auto;
background: linear-gradient(180deg, rgba(18,25,44,0.98), rgba(10,14,25,0.98));
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
}
.modal-head, .modal-foot {
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid var(--line);
}
.modal-foot { border-top: 1px solid var(--line); border-bottom: 0; }
.modal-body { padding: 18px; }
.detail-grid {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 16px;
}
@media (max-width: 840px) {
.detail-grid { grid-template-columns: 1fr; }
}
.detail-box {
background: rgba(255,255,255,0.05);
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px;
}
.detail-box h4 {
margin: 0 0 10px;
font-size: 15px;
}
.detail-list {
margin: 0;
padding-left: 18px;
color: var(--text);
line-height: 1.55;
}
.detail-list li { margin-bottom: 6px; }
.callout {
background: rgba(109, 141, 255, 0.12);
border: 1px solid rgba(109, 141, 255, 0.35);
border-radius: 18px;
padding: 12px 14px;
color: var(--text);
line-height: 1.5;
}
.footer-note {
margin-top: 16px;
color: var(--muted);
font-size: 12px;
text-align: center;
line-height: 1.5;
}
.mini-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.mini-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 520px) {
.mini-grid { grid-template-columns: 1fr; }
}
.metric {
padding: 12px;
border-radius: 16px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--line);
}
.metric .num { font-size: 22px; font-weight: 900; }
.metric .txt { font-size: 12px; color: var(--muted); margin-top: 4px; }
.kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.06);
}
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div class="brand">
<h1>Подбор запчастей</h1>
<p>Один файл. Работает как автономный каталог с поиском по автомобилю, VIN, названию детали и необязательным ИИ-помощником. Можно запускать сразу в браузере без сборки.</p>
</div>
<div class="status">
<div class="status-grid">
<div class="pill">
<div class="label">Авто</div>
<div class="value" id="statusCar">Не выбрано</div>
</div>
<div class="pill">
<div class="label">Запчастей</div>
<div class="value" id="statusCount">0</div>
</div>
<div class="pill">
<div class="label">Режим</div>
<div class="value" id="statusMode">Локальный</div>
</div>
</div>
</div>
</div><div class="layout">
<!-- Left column -->
<section class="panel">
<div class="panel-head">
<div>
<h2>Автомобиль</h2>
<small>Подбор по марке, модели, году и двигателю</small>
</div>
<button class="ghost" id="btnResetCar">Сброс</button>
</div>
<div class="panel-body stack">
<div class="field">
<label for="vinInput">VIN</label>
<input id="vinInput" type="text" placeholder="17 символов, например WVWZZZ3CZ8E123456" maxlength="17" autocomplete="off" />
<div class="hint" id="vinHint">Можно просто ввести VIN, чтобы попытаться определить автомобиль по базе.</div>
</div>
<div class="grid-2">
<div class="field">
<label for="brandSelect">Марка</label>
<select id="brandSelect"></select>
</div>
<div class="field">
<label for="modelSelect">Модель</label>
<select id="modelSelect"></select>
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="yearSelect">Год</label>
<select id="yearSelect"></select>
</div>
<div class="field">
<label for="engineSelect">Двигатель</label>
<select id="engineSelect"></select>
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="bodySelect">Кузов</label>
<select id="bodySelect"></select>
</div>
<div class="field">
<label for="transSelect">Коробка</label>
<select id="transSelect"></select>
</div>
</div>
<div class="btn-row">
<button id="btnApplyCar">Применить</button>
<button class="secondary" id="btnFindByVin">По VIN</button>
</div>
<div class="hint">
Подсказка: сначала выбери автомобиль вручную, потом ищи деталь. Для более точного поиска можно использовать VIN или сразу написать в поиск название детали.
</div>
<div>
<div class="tiny" style="margin-bottom:8px;">Популярные быстрые шаблоны</div>
<div class="car-chip-list" id="quickCars"></div>
</div>
<div class="divider"></div>
<div class="mini-grid">
<div class="metric"><div class="num" id="metricBrands">0</div><div class="txt">марок в базе</div></div>
<div class="metric"><div class="num" id="metricModels">0</div><div class="txt">моделей</div></div>
<div class="metric"><div class="num" id="metricParts">0</div><div class="txt">типов запчастей</div></div>
<div class="metric"><div class="num" id="metricFavs">0</div><div class="txt">избранных</div></div>
</div>
</div>
</section>
<!-- Middle column -->
<section class="panel">
<div class="panel-head">
<div>
<h2>Каталог</h2>
<small>Поиск по названию, OEM, аналогу или функции</small>
</div>
<small id="resultsMeta">0 найдено</small>
</div>
<div class="panel-body">
<div class="stack" style="margin-bottom:14px;">
<div class="field">
<label for="searchInput">Поиск запчасти</label>
<input id="searchInput" type="search" placeholder="Например: тормозные колодки, фильтр, насос, левый шрус..." />
</div>
<div class="grid-3">
<div class="field">
<label for="categorySelect">Категория</label>
<select id="categorySelect"></select>
</div>
<div class="field">
<label for="priceSelect">Сортировка</label>
<select id="priceSelect">
<option value="relevance">По релевантности</option>
<option value="priceAsc">Цена: по возрастанию</option>
<option value="priceDesc">Цена: по убыванию</option>
<option value="nameAsc">Название: A→Z</option>
</select>
</div>
<div class="field">
<label for="stockFilter">Наличие</label>
<select id="stockFilter">
<option value="all">Любое</option>
<option value="in">В наличии</option>
<option value="few">Мало</option>
<option value="out">Нет</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="success" id="btnSearch">Найти</button>
<button class="secondary" id="btnClearFilters">Очистить фильтры</button>
</div>
</div>
<div class="results-head">
<div>
<h3>Результаты</h3>
<p id="resultsDescription">Выбери автомобиль и введи запрос, чтобы увидеть совместимые позиции.</p>
</div>
<div class="tiny">Быстрый фильтр: <span class="kbd">/</span> фокус на поиск</div>
</div>
<div class="list" id="resultsList"></div>
</div>
</section>
<!-- Right column -->
<section class="panel">
<div class="panel-head">
<div>
<h2>ИИ / Помощник</h2>
<small>Локально или через Mistral, если ключ введён вручную</small>
</div>
<small id="aiState">Локальные подсказки</small>
</div>
<div class="right-tabs">
<button class="tab active" data-tab="assistant">Помощник</button>
<button class="tab" data-tab="favorites">Избранное</button>
<button class="tab" data-tab="history">История</button>
<button class="tab" data-tab="settings">Настройки</button>
</div>
<div class="panel-body">
<div id="tab-assistant" class="tab-page chat">
<div class="chat-log" id="chatLog"></div>
<div class="field">
<label for="chatInput">Сообщение</label>
<textarea id="chatInput" placeholder="Напиши: нужны колодки на passat b6 2.0 tdi или какие аналоги лучше для фильтра масла"></textarea>
</div>
<div class="btn-row">
<button id="btnAskAI">Спросить</button>
<button class="secondary" id="btnSuggestFromCar">Подобрать по авто</button>
</div>
<div class="hint">
ИИ здесь работает как помощник по каталогу: он не выдумывает совместимость, а опирается на базу и подсказки. Ключ Mistral можно вставить в настройках — код сам отправит запросы на API.
</div>
</div>
<div id="tab-favorites" class="tab-page" style="display:none;">
<div class="stack">
<div class="tiny">Сохранённые позиции</div>
<div class="list" id="favoritesList"></div>
</div>
</div>
<div id="tab-history" class="tab-page" style="display:none;">
<div class="stack">
<div class="tiny">Последние запросы</div>
<div class="list" id="historyList"></div>
</div>
</div>
<div id="tab-settings" class="tab-page" style="display:none;">
<div class="stack">
<div class="field">
<label for="apiKeyInput">Mistral API ключ</label>
<input id="apiKeyInput" type="password" placeholder="Вставь ключ вручную, если хочешь включить облачный ИИ" />
<div class="hint">Ключ хранится только в браузере в localStorage на этом устройстве. Лучше использовать отдельный тестовый ключ и не публиковать этот файл с ключом внутри.</div>
</div>
<div class="field">
<label for="modelInput">Модель</label>
<input id="modelInput" type="text" value="mistral-small-latest" />
</div>
<div class="grid-2">
<button id="btnSaveSettings">Сохранить</button>
<button class="secondary" id="btnClearData">Стереть данные</button>
</div>
<div class="callout">
По умолчанию приложение работает полностью локально. Если ключ не задан, ответы строятся из правил и базы совместимости. Это безопаснее и быстрее для простого подбора.
</div>
</div>
</div>
</div>
</section>
</div>
<div class="footer-note">
Горячие клавиши: <span class="kbd">/</span> — поиск, <span class="kbd">Enter</span> — применить первый подходящий поиск, <span class="kbd">Esc</span> — закрыть окно деталей.
</div>
</div> <!-- Modal --> <div class="modal-backdrop" id="modalBackdrop" aria-hidden="true">
<div class="modal">
<div class="modal-head">
<div>
<div class="tiny" id="modalCategory">Категория</div>
<h3 style="margin:4px 0 0;" id="modalTitle">Название детали</h3>
</div>
<button class="ghost" id="btnCloseModal">Закрыть</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-foot">
<div class="tiny" id="modalNote">Совместимость и аналоги основаны на локальной базе.</div>
<div class="btn-row">
<button class="secondary" id="btnModalFav">В избранное</button>
<button id="btnModalUse">Подобрать похожие</button>
</div>
</div>
</div>
</div> <script>
(() => {
const STORAGE = {
settings: 'parts_picker_settings_v1',
favorites: 'parts_picker_favorites_v1',
history: 'parts_picker_history_v1',
lastVehicle: 'parts_picker_vehicle_v1',
};
const DB = {
vehicles: [
{
brand: 'Volkswagen',
models: [
{
name: 'Passat B6',
years: [2006, 2007, 2008, 2009, 2010],
bodies: ['Sedan', 'Variant'],
transmissions: ['MT', 'AT', 'DSG'],
engines: [
{ code: '1.6 BSE', fuel: 'Бензин', hp: 102 },
{ code: '1.8 TSI CABB', fuel: 'Бензин', hp: 160 },
{ code: '2.0 TDI BMP', fuel: 'Дизель', hp: 140 },
{ code: '2.0 TDI CBAA', fuel: 'Дизель', hp: 140 },
],
vins: ['WVWZZZ3CZ8E123456', 'WVWZZZ3CZ9P123456', 'WVWZZZ3CZ0P987654'],
},
{
name: 'Golf 5',
years: [2004, 2005, 2006, 2007, 2008],
bodies: ['Hatchback', 'Variant'],
transmissions: ['MT', 'AT', 'DSG'],
engines: [
{ code: '1.4 BUD', fuel: 'Бензин', hp: 80 },
{ code: '1.6 BSE', fuel: 'Бензин', hp: 102 },
{ code: '2.0 TDI BKD', fuel: 'Дизель', hp: 140 },
],
vins: ['WVWZZZ1KZ6W123456', 'WVWZZZ1KZ7W654321'],
},
],
},
{
brand: 'Skoda',
models: [
{
name: 'Octavia A5',
years: [2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011],
bodies: ['Liftback', 'Combi'],
transmissions: ['MT', 'AT', 'DSG'],
engines: [
{ code: '1.6 BSE', fuel: 'Бензин', hp: 102 },
{ code: '1.8 TSI CDAA', fuel: 'Бензин', hp: 160 },
{ code: '1.9 TDI BXE', fuel: 'Дизель', hp: 105 },
{ code: '2.0 TDI BKD', fuel: 'Дизель', hp: 140 },
],
vins: ['TMBZZZ1Z8A1123456', 'TMBZZZ1Z9B2456789'],
},
],
},
{
brand: 'Toyota',
models: [
{
name: 'Camry XV40',
years: [2006, 2007, 2008, 2009, 2010],
bodies: ['Sedan'],
transmissions: ['AT'],
engines: [
{ code: '2.4 2AZ-FE', fuel: 'Бензин', hp: 167 },
{ code: '3.5 2GR-FE', fuel: 'Бензин', hp: 277 },
],
vins: ['JTNBE40K303123456', 'JTNBF30K803654321'],
},
{
name: 'Corolla E150',
years: [2007, 2008, 2009, 2010, 2011, 2012],
bodies: ['Sedan', 'Hatchback'],
transmissions: ['MT', 'AT'],
engines: [
{ code: '1.6 1ZR-FE', fuel: 'Бензин', hp: 124 },
{ code: '1.4 D-4D 1ND-TV', fuel: 'Дизель', hp: 90 },
],
vins: ['JTNBH56E001234567', 'JTNBA52E001112233'],
}
],
},
{
brand: 'BMW',
models: [
{
name: '3 Series E90',
years: [2005, 2006, 2007, 2008, 2009, 2010, 2011],
bodies: ['Sedan', 'Touring'],
transmissions: ['MT', 'AT', 'SMG'],
engines: [
{ code: '320d M47', fuel: 'Дизель', hp: 163 },
{ code: '318i N46', fuel: 'Бензин', hp: 129 },
{ code: '330i N52', fuel: 'Бензин', hp: 258 },
],
vins: ['WBAWD11070P123456', 'WBAUX31020P987654'],
},
],
}
],
parts: [
{
id: 'brk-pads-front',
name: 'Тормозные колодки передние',
category: 'Тормозная система',
oem: ['1K0698151A', '3C0698151'],
synonyms: ['колодки', 'передние колодки', 'brake pads', 'передний тормоз'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Смотри толщину диска и тип суппорта перед заказом.',
price: 42,
stock: 'in',
brands: ['ATE', 'TRW', 'Brembo', 'Bosch'],
compatible: ['Sedan', 'Hatchback', 'Liftback', 'Variant', 'Combi', 'Touring'],
},
{
id: 'brk-pads-rear',
name: 'Тормозные колодки задние',
category: 'Тормозная система',
oem: ['5Q0698451', '8K0698451'],
synonyms: ['задние колодки', 'rear pads'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Иногда нужен электроручник или другой комплект скоб.',
price: 38,
stock: 'few',
brands: ['ATE', 'Textar', 'TRW'],
compatible: ['Sedan', 'Hatchback', 'Liftback', 'Variant', 'Combi', 'Touring'],
},
{
id: 'oil-filter',
name: 'Масляный фильтр',
category: 'Двигатель',
oem: ['06D115562', '03C115561H'],
synonyms: ['фильтр масла', 'oil filter'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90', 'Toyota Corolla E150'],
engines: ['*'],
notes: 'Подбирай по резьбе, высоте и клапану перепуска.',
price: 11,
stock: 'in',
brands: ['MANN', 'Mahle', 'Bosch', 'Hengst'],
compatible: ['*'],
},
{
id: 'air-filter',
name: 'Воздушный фильтр',
category: 'Двигатель',
oem: ['1K0129620D', '8K0133843'],
synonyms: ['фильтр воздуха', 'air filter'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Для дизеля и бензина часто разные размеры корпуса.',
price: 15,
stock: 'in',
brands: ['MANN', 'Knecht', 'Bosch'],
compatible: ['*'],
},
{
id: 'cabin-filter',
name: 'Салонный фильтр',
category: 'Фильтры',
oem: ['1K0819644B', '87139-0D010'],
synonyms: ['фильтр салона', 'пылевой фильтр'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Есть угольные и обычные версии.',
price: 14,
stock: 'in',
brands: ['MANN', 'Mahle', 'WIX'],
compatible: ['*'],
},
{
id: 'fuel-filter-diesel',
name: 'Топливный фильтр дизель',
category: 'Фильтры',
oem: ['1K0127434A', '191127401A'],
synonyms: ['diesel filter', 'солярный фильтр'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5'],
engines: ['Дизель'],
notes: 'Проверь наличие подогрева и тип корпуса.',
price: 19,
stock: 'few',
brands: ['MANN', 'Mahle', 'Bosch'],
compatible: ['*'],
},
{
id: 'spark-plugs',
name: 'Свечи зажигания',
category: 'Двигатель',
oem: ['101905611G', '22401-1FDAC'],
synonyms: ['свечи', 'spark plugs'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['Бензин'],
notes: 'Надо сверять калильное число и зазор.',
price: 24,
stock: 'in',
brands: ['NGK', 'Denso', 'Bosch'],
compatible: ['*'],
},
{
id: 'glow-plugs',
name: 'Свечи накаливания',
category: 'Двигатель',
oem: ['N10579805', 'N0252151'],
synonyms: ['накал', 'glow plugs'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5'],
engines: ['Дизель'],
notes: 'Часто есть разница по резьбе и длине стержня.',
price: 28,
stock: 'few',
brands: ['Bosch', 'NGK', 'Beru'],
compatible: ['*'],
},
{
id: 'front-shock',
name: 'Амортизатор передний',
category: 'Подвеска',
oem: ['1K0413031BK', '334839'],
synonyms: ['стойка', 'shock absorber', 'аморт'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Смотри тип подвески, диаметр штока и спортивную/обычную версию.',
price: 67,
stock: 'in',
brands: ['KYB', 'Sachs', 'Bilstein'],
compatible: ['*'],
},
{
id: 'rear-shock',
name: 'Амортизатор задний',
category: 'Подвеска',
oem: ['1K0513025CF', '343399'],
synonyms: ['задняя стойка', 'rear shock'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'На универсалах и седанах бывают разные длины.',
price: 61,
stock: 'in',
brands: ['KYB', 'Sachs', 'Bilstein'],
compatible: ['*'],
},
{
id: 'air-suspension-note',
name: 'Пружина подвески',
category: 'Подвеска',
oem: ['1K0411105', '481311'],
synonyms: ['пружина', 'spring'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Обычно подбирается по VIN и цветовым меткам.',
price: 33,
stock: 'few',
brands: ['Lesjöfors', 'H&R', 'Sachs'],
compatible: ['*'],
},
{
id: 'wheel-bearing',
name: 'Ступичный подшипник',
category: 'Подвеска',
oem: ['1K0498621', '42450-02050'],
synonyms: ['ступица', 'hub bearing'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Проверь ABS-датчик и число шлицов.',
price: 49,
stock: 'in',
brands: ['SKF', 'FAG', 'Koyo'],
compatible: ['*'],
},
{
id: 'timing-kit',
name: 'Комплект ГРМ',
category: 'Двигатель',
oem: ['03L198119F', '2AZFE-TIMING'],
synonyms: ['грм', 'ремень грм', 'timing kit'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Нужно сверять мотор, помпу и ролики. Для некоторых двигателей цепь вместо ремня.',
price: 145,
stock: 'in',
brands: ['INA', 'ContiTech', 'Gates'],
compatible: ['*'],
},
{
id: 'water-pump',
name: 'Помпа охлаждения',
category: 'Охлаждение',
oem: ['06A121011', '16100-09280'],
synonyms: ['водяная помпа', 'water pump'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150'],
engines: ['*'],
notes: 'Часто берут вместе с комплектом ГРМ.',
price: 54,
stock: 'in',
brands: ['HEPU', 'Saleri', 'Graf'],
compatible: ['*'],
},
{
id: 'starter',
name: 'Стартер',
category: 'Электрика',
oem: ['02M911023P', '28100-0D090'],
synonyms: ['start', 'стартер двигателя'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Важно смотреть мощность, крепление и число зубьев бендикса.',
price: 129,
stock: 'few',
brands: ['Bosch', 'Valeo', 'Denso'],
compatible: ['*'],
},
{
id: 'alternator',
name: 'Генератор',
category: 'Электрика',
oem: ['06F903023D', '27060-0D090'],
synonyms: ['alternator', 'генератор двигателя'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Проверь ток зарядки и разъем регулятора.',
price: 164,
stock: 'in',
brands: ['Bosch', 'Valeo', 'Denso'],
compatible: ['*'],
},
{
id: 'headlight-bulb',
name: 'Лампа фары',
category: 'Электрика',
oem: ['H7', 'D2S', 'H11'],
synonyms: ['лампа', 'bulb', 'лампа ближнего света'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Нужно знать тип фары: галоген, ксенон или LED.',
price: 9,
stock: 'in',
brands: ['Philips', 'Osram', 'Bosch'],
compatible: ['*'],
},
{
id: 'drive-shaft-out',
name: 'ШРУС наружный',
category: 'Трансмиссия',
oem: ['1K0498099B', '43460-0D040'],
synonyms: ['шрус', 'наружный шрус', 'cv joint'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Corolla E150', 'BMW 3 Series E90'],
engines: ['*'],
notes: 'Сверь число шлицов и ABS-кольцо.',
price: 58,
stock: 'few',
brands: ['SKF', 'GKN', 'SNR'],
compatible: ['*'],
},
{
id: 'injector-seal',
name: 'Уплотнение форсунки',
category: 'Двигатель',
oem: ['03G198051', '23681-0D010'],
synonyms: ['прокладка форсунки', 'injector seal'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5'],
engines: ['Дизель'],
notes: 'Лучше менять комплектом с шайбами и болтами.',
price: 7,
stock: 'in',
brands: ['Victor Reinz', 'Elring', 'Bosch'],
compatible: ['*'],
},
{
id: 'belt-tensioner',
name: 'Натяжитель ремня',
category: 'Двигатель',
oem: ['03C145299C', '16620-0H010'],
synonyms: ['натяжной ролик', 'tensioner'],
fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150'],
engines: ['*'],
notes: 'Сверяй длину ремня и схему навесного оборудования.',
price: 44,
stock: 'in',
brands: ['INA', 'Gates', 'Dayco'],
compatible: ['*'],
}
],
};
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = {
brand: '',
model: '',
year: '',
engine: '',
body: '',
trans: '',
vin: '',
category: 'all',
search: '',
stock: 'all',
sort: 'relevance',
activePart: null,
activeTab: 'assistant',
aiMode: 'local',
settings: {
apiKey: '',
model: 'mistral-small-latest',
},
favorites: [],
history: [],
};
const categoryOptions = ['all', ...new Set(DB.parts.map(p => p.category))];
function loadStorage() {
try {
const s = JSON.parse(localStorage.getItem(STORAGE.settings) || 'null');
if (s && typeof s === 'object') state.settings = { ...state.settings, ...s };
} catch {}
try {
state.favorites = JSON.parse(localStorage.getItem(STORAGE.favorites) || '[]');
} catch { state.favorites = []; }
try {
state.history = JSON.parse(localStorage.getItem(STORAGE.history) || '[]');
} catch { state.history = []; }
try {
const v = JSON.parse(localStorage.getItem(STORAGE.lastVehicle) || 'null');
if (v) state.brand = v.brand || '';
if (v) state.model = v.model || '';
if (v) state.year = String(v.year || '');
if (v) state.engine = v.engine || '';
if (v) state.body = v.body || '';
if (v) state.trans = v.trans || '';
} catch {}
}
function saveStorage() {
localStorage.setItem(STORAGE.settings, JSON.stringify(state.settings));
localStorage.setItem(STORAGE.favorites, JSON.stringify(state.favorites));
localStorage.setItem(STORAGE.history, JSON.stringify(state.history.slice(0, 20)));
localStorage.setItem(STORAGE.lastVehicle, JSON.stringify({
brand: state.brand,
model: state.model,
year: state.year,
engine: state.engine,
body: state.body,
trans: state.trans,
}));
}
function getModelsByBrand(brand) {
const item = DB.vehicles.find(v => v.brand === brand);
return item ? item.models : [];
}
function currentCarKey() {
if (!state.brand || !state.model) return '';
return `${state.brand} ${state.model}`;
}
function currentVehicle() {
const brand = DB.vehicles.find(v => v.brand === state.brand);
const model = brand?.models.find(m => m.name === state.model);
return { brand, model };
}
function getAvailableYears() {
const { model } = currentVehicle();
return model ? [...model.years] : [];
}
function getAvailableEngines() {
const { model } = currentVehicle();
return model ? [...model.engines] : [];
}
function fillSelect(select, values, placeholder) {
select.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = placeholder;
select.appendChild(opt);
values.forEach(v => {
const o = document.createElement('option');
o.value = typeof v === 'string' ? v : String(v);
o.textContent = typeof v === 'string' ? v : String(v);
select.appendChild(o);
});
}
function initSelectors() {
fillSelect($('#brandSelect'), DB.vehicles.map(v => v.brand), 'Выбери марку');
fillSelect($('#categorySelect'), categoryOptions, 'Все категории');
$('#categorySelect').value = state.category;
renderBrandModels();
renderYearsAndEngines();
renderBodiesAndTrans();
$('#brandSelect').value = state.brand;
$('#modelSelect').value = state.model;
$('#yearSelect').value = state.year;
$('#engineSelect').value = state.engine;
$('#bodySelect').value = state.body;
$('#transSelect').value = state.trans;
$('#searchInput').value = state.search;
$('#stockFilter').value = state.stock;
$('#priceSelect').value = state.sort;
$('#vinInput').value = state.vin;
$('#apiKeyInput').value = state.settings.apiKey;
$('#modelInput').value = state.settings.model;
}
function renderBrandModels() {
const models = getModelsByBrand(state.brand);
fillSelect($('#modelSelect'), models.map(m => m.name), 'Выбери модель');
$('#brandSelect').value = state.brand;
$('#modelSelect').value = state.model;
}
function renderYearsAndEngines() {
const { model } = currentVehicle();
fillSelect($('#yearSelect'), model ? model.years : [], 'Выбери год');
fillSelect($('#engineSelect'), model ? model.engines.map(e => e.code) : [], 'Выбери двигатель');
$('#yearSelect').value = state.year;
$('#engineSelect').value = state.engine;
}
function renderBodiesAndTrans() {
const { model } = currentVehicle();
fillSelect($('#bodySelect'), model ? model.bodies : [], 'Выбери кузов');
fillSelect($('#transSelect'), model ? model.transmissions : [], 'Выбери коробку');
$('#bodySelect').value = state.body;
$('#transSelect').value = state.trans;
}
function fillQuickCars() {
const quickCars = [
['Volkswagen', 'Passat B6'],
['Skoda', 'Octavia A5'],
['Toyota', 'Camry XV40'],
['BMW', '3 Series E90'],
];
const wrap = $('#quickCars');
wrap.innerHTML = '';
quickCars.forEach(([brand, model]) => {
const chip = document.createElement('div');
chip.className = 'chip';
chip.textContent = `${brand} ${model}`;
chip.onclick = () => setVehicle(brand, model);
wrap.appendChild(chip);
});
}
function setVehicle(brand, model) {
state.brand = brand;
state.model = model;
const cv = currentVehicle().model;
state.year = cv?.years?.[0] ? String(cv.years[0]) : '';
state.engine = cv?.engines?.[0]?.code || '';
state.body = cv?.bodies?.[0] || '';
state.trans = cv?.transmissions?.[0] || '';
syncVehicleControls();
saveStorage();
render();
}
function syncVehicleControls() {
renderBrandModels();
renderYearsAndEngines();
renderBodiesAndTrans();
$('#brandSelect').value = state.brand;
$('#modelSelect').value = state.model;
$('#yearSelect').value = state.year;
$('#engineSelect').value = state.engine;
$('#bodySelect').value = state.body;
$('#transSelect').value = state.trans;
}
function normalize(text) {
return String(text || '')
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[^a-zа-я0-9]+/gi, ' ')
.trim();
}
function vehicleMatchesPart(part) {
const key = currentCarKey();
if (!key) return true;
return part.fits.includes(key) || part.fits.includes('*');
}
function partScore(part, query) {
const q = normalize(query);
const tokens = q.split(/\s+/).filter(Boolean);
if (!tokens.length) return vehicleMatchesPart(part) ? 10 : 0;
let score = 0;
const hay = normalize([part.name, part.category, part.oem.join(' '), part.synonyms.join(' '), part.notes, part.brands.join(' ')].join(' '));
if (hay.includes(q)) score += 100;
for (const t of tokens) {
if (hay.includes(t)) score += 12;
if (normalize(part.name).includes(t)) score += 10;
if (normalize(part.category).includes(t)) score += 8;
if (part.oem.some(o => normalize(o).includes(t))) score += 10;
}
if (vehicleMatchesPart(part)) score += 20;
if (state.category !== 'all' && part.category === state.category) score += 15;
if (state.stock !== 'all' && part.stock === state.stock) score += 10;
if (state.year && part.notes.toLowerCase().includes(state.year)) score += 1;
return score;
}
function filteredParts() {
const query = state.search || '';
let arr = DB.parts.filter(part => {
if (state.category !== 'all' && part.category !== state.category) return false;
if (state.stock !== 'all' && part.stock !== state.stock) return false;
if (state.brand || state.model || state.year || state.engine || state.body || state.trans) {
if (!vehicleMatchesPart(part)) return false;
}
if (!query.trim()) return true;
const q = normalize(query);
const hay = normalize([part.name, part.category, part.oem.join(' '), part.synonyms.join(' '), part.notes, part.brands.join(' ')].join(' '));
return hay.includes(q) || q.split(/\s+/).every(t => hay.includes(t));
});
if (state.sort === 'priceAsc') arr.sort((a, b) => a.price - b.price);
else if (state.sort === 'priceDesc') arr.sort((a, b) => b.price - a.price);
else if (state.sort === 'nameAsc') arr.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
else arr.sort((a, b) => partScore(b, query) - partScore(a, query));
return arr;
}
function stockText(stock) {
if (stock === 'in') return 'В наличии';
if (stock === 'few') return 'Мало';
return 'Нет в наличии';
}
function stockColorLabel(stock) {
if (stock === 'in') return 'success';
if (stock === 'few') return 'warn';
return 'danger';
}
function renderResults() {
const list = $('#resultsList');
const parts = filteredParts();
$('#statusCount').textContent = String(parts.length);
$('#resultsMeta').textContent = `${parts.length} найдено`;
const carLabel = currentCarKey() || 'Не выбрано';
$('#statusCar').textContent = carLabel;
const desc = [];
if (state.search) desc.push(`запрос: «${state.search}»`);
if (state.category !== 'all') desc.push(`категория: ${state.category}`);
if (currentCarKey()) desc.push(`авто: ${carLabel}`);
$('#resultsDescription').textContent = desc.length ? desc.join(' • ') : 'Выбери автомобиль и введи запрос, чтобы увидеть совместимые позиции.';
if (!parts.length) {
list.innerHTML = `<div class="part-card"><strong>Ничего не найдено</strong><div class="hint">Попробуй убрать фильтр категории, изменить двигатель, либо написать синоним: например «фильтр масла», «колодки», «шрус».</div></div>`;
return;
}
list.innerHTML = '';
parts.forEach(part => {
const el = document.createElement('div');
el.className = 'part-card';
const fav = state.favorites.some(f => f.id === part.id);
el.innerHTML = `
<div class="part-top">
<div>
<h4 class="part-name">${escapeHtml(part.name)}</h4>
<div class="part-meta">
<span class="tag"><strong>${escapeHtml(part.category)}</strong></span>
<span class="tag">OEM: <strong>${escapeHtml(part.oem[0] || '-')}</strong></span>
<span class="tag">Бренды: <strong>${escapeHtml(part.brands.slice(0, 2).join(', '))}</strong></span>
<span class="tag">Наличие: <strong>${stockText(part.stock)}</strong></span>
</div>
</div>
<div class="price">${part.price} $</div>
</div>
<div class="hint">${escapeHtml(part.notes)}</div>
<div class="part-actions">
<button data-open="${part.id}">Открыть</button>
<button class="secondary" data-fav="${part.id}">${fav ? 'Убрать из избранного' : 'В избранное'}</button>
<button class="secondary" data-similar="${part.id}">Похожие</button>
</div>
`;
list.appendChild(el);
});
list.querySelectorAll('[data-open]').forEach(btn => btn.addEventListener('click', () => openPart(btn.dataset.open)));
list.querySelectorAll('[data-fav]').forEach(btn => btn.addEventListener('click', () => toggleFavorite(btn.dataset.fav)));
list.querySelectorAll('[data-similar]').forEach(btn => btn.addEventListener('click', () => showSimilar(btn.dataset.similar)));
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function openPart(id) {
const part = DB.parts.find(p => p.id === id);
if (!part) return;
state.activePart = part;
recordHistory(`Открыл: ${part.name}`);
$('#modalCategory').textContent = part.category;
$('#modalTitle').textContent = part.name;
$('#modalBody').innerHTML = `
<div class="detail-grid">
<div class="detail-box">
<h4>Описание</h4>
<div class="callout" style="margin-bottom:12px;">${escapeHtml(part.notes)}</div>
<div class="grid-2">
<div>
<div class="tiny">OEM номера</div>
<ul class="detail-list">${part.oem.map(o => `<li>${escapeHtml(o)}</li>`).join('')}</ul>
</div>
<div>
<div class="tiny">Бренды-аналоги</div>
<ul class="detail-list">${part.brands.map(o => `<li>${escapeHtml(o)}</li>`).join('')}</ul>
</div>
</div>
<div class="divider"></div>
<div class="tiny">Совместимые авто</div>
<div class="car-chip-list" style="margin-top:8px;">${part.fits.slice(0, 8).map(v => `<span class="chip" data-vehicle-chip="${escapeHtml(v)}">${escapeHtml(v)}</span>`).join('')}</div>
</div>
<div class="detail-box">
<h4>Советы по подбору</h4>
<ul class="detail-list">
<li>Сверь VIN, год и мотор.</li>
<li>Проверь фото старой детали и номер на корпусе.</li>
<li>Сравни крепления, разъемы и размеры.</li>
<li>Если есть сомнение, открой «Похожие» и сравни аналоги.</li>
</ul>
<div class="divider"></div>
<div class="tiny">Быстрые данные</div>
<div class="stack" style="margin-top:8px;">
<div class="tag"><strong>Цена:</strong>&nbsp;${part.price} $</div>
<div class="tag"><strong>Наличие:</strong>&nbsp;${stockText(part.stock)}</div>
<div class="tag"><strong>Категория:</strong>&nbsp;${escapeHtml(part.category)}</div>
<div class="tag"><strong>Совместимость:</strong>&nbsp;${part.compatible.includes('*') ? 'Универсально по кузову' : part.compatible.join(', ')}</div>
</div>
</div>
</div>
`;
$('#btnModalFav').textContent = state.favorites.some(f => f.id === part.id) ? 'Убрать из избранного' : 'В избранное';
$('#modalBackdrop').classList.add('show');
$('#modalBackdrop').setAttribute('aria-hidden', 'false');
$('#modalBody').querySelectorAll('[data-vehicle-chip]').forEach(chip => {
chip.addEventListener('click', () => {
const [brand, ...rest] = chip.dataset.vehicleChip.split(' ');
const model = rest.join(' ');
const exact = DB.vehicles.find(v => v.brand === brand);
if (exact) setVehicle(brand, model || exact.models[0].name);
});
});
}
function closeModal() {
$('#modalBackdrop').classList.remove('show');
$('#modalBackdrop').setAttribute('aria-hidden', 'true');
state.activePart = null;
}
function toggleFavorite(id) {
const part = DB.parts.find(p => p.id === id);
if (!part) return;
const idx = state.favorites.findIndex(f => f.id === id);
if (idx >= 0) {
state.favorites.splice(idx, 1);
addBubble('ai', `Убрано из избранного: ${part.name}`);
} else {
state.favorites.unshift({ id: part.id, name: part.name, category: part.category, price: part.price });
addBubble('ai', `Добавлено в избранное: ${part.name}`);
}
saveStorage();
renderSidebarLists();
renderMetrics();
renderResults();
if (state.activePart && state.activePart.id === id) $('#btnModalFav').textContent = state.favorites.some(f => f.id === id) ? 'Убрать из избранного' : 'В избранное';
}
function showSimilar(id) {
const part = DB.parts.find(p => p.id === id);
if (!part) return;
const base = normalize(part.name + ' ' + part.category + ' ' + part.synonyms.join(' '));
state.search = part.name;
$('#searchInput').value = part.name;
state.category = part.category;
$('#categorySelect').value = state.category;
recordHistory(`Ищу похожие на: ${part.name}`);
render();
addBubble('ai', `Показал похожие на «${part.name}». Если хочешь, могу сузить выдачу по OEM или по марке.`);
}
function renderSidebarLists() {
const fav = $('#favoritesList');
if (!state.favorites.length) {
fav.innerHTML = '<div class="hint">Пока пусто. Добавляй позиции из каталога.</div>';
} else {
fav.innerHTML = state.favorites.map(item => `
<div class="part-card">
<div class="part-top">
<div>
<div class="tiny">${escapeHtml(item.category)}</div>
<h4 class="part-name">${escapeHtml(item.name)}</h4>
</div>
<div class="price">${item.price} $</div>
</div>
<div class="part-actions">
<button data-open-fav="${item.id}">Открыть</button>
<button class="secondary" data-remove-fav="${item.id}">Убрать</button>
</div>
</div>
`).join('');
fav.querySelectorAll('[data-open-fav]').forEach(btn => btn.addEventListener('click', () => openPart(btn.dataset.openFav)));
fav.querySelectorAll('[data-remove-fav]').forEach(btn => btn.addEventListener('click', () => toggleFavorite(btn.dataset.removeFav)));
}
const hist = $('#historyList');
if (!state.history.length) {
hist.innerHTML = '<div class="hint">История пустая.</div>';
} else {
hist.innerHTML = state.history.slice(0, 12).map(x => `<div class="part-card"><div>${escapeHtml(x)}</div></div>`).join('');
}
}
function renderMetrics() {
$('#metricBrands').textContent = String(DB.vehicles.length);
$('#metricModels').textContent = String(DB.vehicles.reduce((s, v) => s + v.models.length, 0));
$('#metricParts').textContent = String(DB.parts.length);
$('#metricFavs').textContent = String(state.favorites.length);
}
function recordHistory(text) {
state.history.unshift(`${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}${text}`);
state.history = state.history.slice(0, 20);
saveStorage();
renderSidebarLists();
}
function addBubble(type, text) {
const log = $('#chatLog');
const bubble = document.createElement('div');
bubble.className = `bubble ${type}`;
bubble.textContent = text;
log.appendChild(bubble);
log.scrollTop = log.scrollHeight;
}
function bootChat() {
if (!$('#chatLog').children.length) {
addBubble('ai', 'Привет. Я помогу подобрать запчасть по марке, модели, мотору, VIN или названию детали. Напиши запрос в обычной форме, например: «нужен масляный фильтр на Passat B6 2.0 TDI».');
}
}
function extractIntent(query) {
const q = normalize(query);
const tokens = q.split(/\s+/).filter(Boolean);
let brand = DB.vehicles.find(v => q.includes(normalize(v.brand)))?.brand || '';
let model = '';
let year = '';
let engine = '';
let body = '';
let trans = '';
for (const v of DB.vehicles) {
for (const m of v.models) {
if (q.includes(normalize(m.name))) {
brand = v.brand;
model = m.name;
const y = m.years.find(y => q.includes(String(y)));
if (y) year = String(y);
const e = m.engines.find(e => q.includes(normalize(e.code)) || q.includes(normalize(e.fuel)));
if (e) engine = e.code;
const b = m.bodies.find(b => q.includes(normalize(b)));
if (b) body = b;
const t = m.transmissions.find(t => q.includes(normalize(t)));
if (t) trans = t;
}
}
}
const part = DB.parts.find(p => {
const hay = normalize([p.name, p.category, p.synonyms.join(' '), p.oem.join(' ')].join(' '));
return tokens.some(t => hay.includes(t));
});
const vin = q.match(/[a-hj-npr-z0-9]{17}/i)?.[0]?.toUpperCase() || '';
return { brand, model, year, engine, body, trans, part, vin };
}
function localAdvice(query) {
const q = normalize(query);
const intent = extractIntent(query);
const suggestions = [];
if (intent.vin) suggestions.push(`VIN похож на: ${intent.vin}. Сначала проверь его по базе, а затем сравни кузов и двигатель.`);
if (intent.brand || intent.model) suggestions.push(`Похоже на авто: ${[intent.brand, intent.model].filter(Boolean).join(' ')}${intent.year ? ', ' + intent.year : ''}${intent.engine ? ', ' + intent.engine : ''}.`);
if (intent.part) {
const matches = DB.parts.filter(p => p.category === intent.part.category && vehicleMatchesPart(p)).slice(0, 4);
suggestions.push(`Похоже, ты ищешь: ${intent.part.name}.`);
suggestions.push(`Похожие варианты: ${matches.map(m => m.name).join(', ')}.`);
}
if (/колодк|brake/.test(q)) suggestions.push('Для колодок проверь диаметр диска, тип суппорта и наличие датчика износа.');
if (/фильтр/.test(q)) suggestions.push('Для фильтров сверяй корпус, размеры и клапаны.');
if (/шрус|cv/.test(q)) suggestions.push('Для ШРУСа критичны шлицы, ABS-кольцо и диаметр посадки.');
if (/грм|ремень|timing/.test(q)) suggestions.push('Для ГРМ обязательно сверяй мотор-код и версию помпы.');
if (/аморт|стойк/.test(q)) suggestions.push('Для подвески важны кузов, тип привода и спортивная/обычная подвеска.');
if (/свеч|spark|glow/.test(q)) suggestions.push('Свечи подбираются строго по типу топлива и мотор-коду.');
if (!suggestions.length) {
suggestions.push('Напиши марку, модель и деталь. Пример: «Skoda Octavia A5 1.9 TDI, нужен салонный фильтр».');
suggestions.push('Можно также просто назвать OEM-номер или код с детали.');
}
return suggestions.join(' ');
}
async function askAI(query) {
recordHistory(`AI: ${query}`);
addBubble('me', query);
const key = state.settings.apiKey.trim();
const model = state.settings.model.trim() || 'mistral-small-latest';
state.aiMode = key ? 'Mistral' : 'Локальный';
$('#aiState').textContent = state.aiMode === 'Mistral' ? `Mistral: ${model}` : 'Локальные подсказки';
$('#statusMode').textContent = state.aiMode;
if (!key) {
const reply = localAdvice(query);
addBubble('ai', reply);
applyIntentToForm(query);
return;
}
addBubble('ai', 'Думаю...');
const log = $('#chatLog');
const thinkingBubble = log.lastElementChild;
try {
const cars = currentCarKey() || 'Не выбрано';
const context = filteredParts().slice(0, 10).map(p => `${p.name} | ${p.category} | OEM: ${p.oem.join(', ')} | fits: ${p.fits.join(', ')} | notes: ${p.notes}`).join('\n');
const prompt = `Ты помощник по подбору автозапчастей. Не выдумывай совместимость. Опирайся на контекст каталога и объясняй сомнения. Если данных мало, проси уточнить марку/модель/год/двигатель/кузов/VIN.\n\nТекущий автомобиль: ${cars}\n\nКаталог (первые позиции):\n${context}\n\nЗапрос пользователя: ${query}`;
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
messages: [
{ role: 'system', content: 'Ты очень аккуратный помощник по автозапчастям.' },
{ role: 'user', content: prompt },
],
}),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`API error ${res.status}: ${txt.slice(0, 220)}`);
}
const data = await res.json();
const text = data?.choices?.[0]?.message?.content?.trim() || 'Нет ответа от модели.';
if (thinkingBubble) thinkingBubble.remove();
addBubble('ai', text);
applyIntentToForm(query);
} catch (err) {
if (thinkingBubble) thinkingBubble.remove();
addBubble('ai', `Не удалось обратиться к Mistral. Переключаюсь на локальный режим.\n${err.message}\n\n${localAdvice(query)}`);
applyIntentToForm(query);
}
}
function applyIntentToForm(query) {
const intent = extractIntent(query);
if (intent.brand) state.brand = intent.brand;
if (intent.model) state.model = intent.model;
if (intent.year) state.year = intent.year;
if (intent.engine) state.engine = intent.engine;
if (intent.body) state.body = intent.body;
if (intent.trans) state.trans = intent.trans;
if (intent.vin) {
state.vin = intent.vin;
$('#vinInput').value = intent.vin;
tryVinLookup(intent.vin);
}
syncVehicleControls();
saveStorage();
if (intent.part) {
state.search = intent.part.name;
$('#searchInput').value = intent.part.name;
}
render();
}
function tryVinLookup(vin) {
vin = String(vin || '').toUpperCase();
const matchedVehicle = DB.vehicles.find(v => v.models.some(m => m.vins.includes(vin)));
if (!matchedVehicle) {
$('#vinHint').innerHTML = `<span class="vin-bad">VIN не найден в локальной базе.</span> Можно всё равно выбрать авто вручную.`;
return false;
}
const model = matchedVehicle.models.find(m => m.vins.includes(vin));
if (model) {
state.brand = matchedVehicle.brand;
state.model = model.name;
state.year = String(model.years[0]);
state.engine = model.engines[0]?.code || '';
state.body = model.bodies[0] || '';
state.trans = model.transmissions[0] || '';
syncVehicleControls();
$('#vinHint').innerHTML = `<span class="vin-ok">VIN распознан.</span> Найдено: ${matchedVehicle.brand} ${model.name}`;
recordHistory(`VIN: ${vin}${matchedVehicle.brand} ${model.name}`);
return true;
}
$('#vinHint').innerHTML = `<span class="vin-bad">VIN не совпал с вариантом модели.</span>`;
return false;
}
function render() {
renderMetrics();
renderResults();
renderSidebarLists();
saveStorage();
}
function bindEvents() {
$('#brandSelect').addEventListener('change', e => {
state.brand = e.target.value;
state.model = '';
state.year = '';
state.engine = '';
state.body = '';
state.trans = '';
syncVehicleControls();
saveStorage();
render();
});
$('#modelSelect').addEventListener('change', e => {
state.model = e.target.value;
const model = currentVehicle().model;
state.year = model?.years[0] ? String(model.years[0]) : '';
state.engine = model?.engines[0]?.code || '';
state.body = model?.bodies[0] || '';
state.trans = model?.transmissions[0] || '';
syncVehicleControls();
saveStorage();
render();
});
$('#yearSelect').addEventListener('change', e => { state.year = e.target.value; saveStorage(); render(); });
$('#engineSelect').addEventListener('change', e => { state.engine = e.target.value; saveStorage(); render(); });
$('#bodySelect').addEventListener('change', e => { state.body = e.target.value; saveStorage(); render(); });
$('#transSelect').addEventListener('change', e => { state.trans = e.target.value; saveStorage(); render(); });
$('#vinInput').addEventListener('input', e => {
state.vin = e.target.value.toUpperCase().replace(/\s+/g, '');
$('#vinInput').value = state.vin;
if (state.vin.length === 17) {
tryVinLookup(state.vin);
saveStorage();
render();
} else {
$('#vinHint').textContent = 'Можно просто ввести VIN, чтобы попытаться определить автомобиль по базе.';
}
});
$('#searchInput').addEventListener('input', e => {
state.search = e.target.value;
renderResults();
saveStorage();
});
$('#categorySelect').addEventListener('change', e => { state.category = e.target.value; render(); });
$('#stockFilter').addEventListener('change', e => { state.stock = e.target.value; render(); });
$('#priceSelect').addEventListener('change', e => { state.sort = e.target.value; render(); });
$('#btnSearch').addEventListener('click', () => {
recordHistory(`Поиск: ${state.search || 'без запроса'}`);
render();
});
$('#btnClearFilters').addEventListener('click', () => {
state.search = '';
state.category = 'all';
state.stock = 'all';
state.sort = 'relevance';
$('#searchInput').value = '';
$('#categorySelect').value = 'all';
$('#stockFilter').value = 'all';
$('#priceSelect').value = 'relevance';
render();
});
$('#btnApplyCar').addEventListener('click', () => {
saveStorage();
recordHistory(`Выбран автомобиль: ${currentCarKey() || 'не выбран'}`);
render();
});
$('#btnFindByVin').addEventListener('click', () => {
if (!state.vin) {
addBubble('ai', 'Сначала введи VIN.');
return;
}
tryVinLookup(state.vin);
render();
});
$('#btnResetCar').addEventListener('click', () => {
state.brand = '';
state.model = '';
state.year = '';
state.engine = '';
state.body = '';
state.trans = '';
state.vin = '';
$('#vinInput').value = '';
$('#vinHint').textContent = 'Можно просто ввести VIN, чтобы попытаться определить автомобиль по базе.';
syncVehicleControls();
render();
});
$('#btnAskAI').addEventListener('click', () => {
const q = $('#chatInput').value.trim();
if (!q) return;
$('#chatInput').value = '';
askAI(q);
});
$('#btnSuggestFromCar').addEventListener('click', () => {
const q = [state.brand, state.model, state.year, state.engine].filter(Boolean).join(' ');
const text = q ? `Подбери запчасти для ${q}` : 'Подбери запчасти по текущему автомобилю';
$('#chatInput').value = text;
askAI(text);
});
$('#btnSaveSettings').addEventListener('click', () => {
state.settings.apiKey = $('#apiKeyInput').value.trim();
state.settings.model = $('#modelInput').value.trim() || 'mistral-small-latest';
saveStorage();
$('#aiState').textContent = state.settings.apiKey ? `Mistral: ${state.settings.model}` : 'Локальные подсказки';
$('#statusMode').textContent = state.settings.apiKey ? 'Mistral' : 'Локальный';
addBubble('ai', state.settings.apiKey ? 'Ключ сохранён. Теперь ответы могут идти через Mistral.' : 'Ключ не задан. Остаёмся в локальном режиме.');
});
$('#btnClearData').addEventListener('click', () => {
if (!confirm('Стереть сохранённые настройки, историю и избранное?')) return;
localStorage.removeItem(STORAGE.settings);
localStorage.removeItem(STORAGE.favorites);
localStorage.removeItem(STORAGE.history);
localStorage.removeItem(STORAGE.lastVehicle);
location.reload();
});
$('#btnCloseModal').addEventListener('click', closeModal);
$('#modalBackdrop').addEventListener('click', (e) => { if (e.target === $('#modalBackdrop')) closeModal(); });
$('#btnModalFav').addEventListener('click', () => { if (state.activePart) toggleFavorite(state.activePart.id); });
$('#btnModalUse').addEventListener('click', () => { if (state.activePart) showSimilar(state.activePart.id); closeModal(); });
$$('.tab').forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== $('#searchInput')) {
e.preventDefault();
$('#searchInput').focus();
}
if (e.key === 'Escape') closeModal();
if (e.key === 'Enter' && document.activeElement === $('#searchInput')) {
e.preventDefault();
recordHistory(`Поиск: ${state.search || 'без запроса'}`);
render();
}
});
}
function switchTab(tab) {
state.activeTab = tab;
$$('.tab').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
$$('.tab-page').forEach(page => page.style.display = 'none');
$(`#tab-${tab}`).style.display = '';
}
function init() {
loadStorage();
initSelectors();
fillQuickCars();
bindEvents();
bootChat();
switchTab('assistant');
$('#aiState').textContent = state.settings.apiKey ? `Mistral: ${state.settings.model}` : 'Локальные подсказки';
$('#statusMode').textContent = state.settings.apiKey ? 'Mistral' : 'Локальный';
if (state.vin) $('#vinInput').value = state.vin;
if (state.vin) tryVinLookup(state.vin);
if (state.brand && state.model) syncVehicleControls();
render();
}
// Fallback: if user clicks a part chip in modal, allow keyboard navigation quickly.
window.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
});
init();
})();
</script></body>
</html>