Complete CarPass product flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 21:19:37 +09:00
parent a83f55c646
commit c0014ab4ea
28 changed files with 3006 additions and 159 deletions

View File

@@ -255,6 +255,9 @@
<button class="menu-row" data-menu-section="fineSection">Штрафы</button>
<button class="menu-row" data-menu-section="publicServicesSection">СТО</button>
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
<button class="menu-row" data-menu-section="connectedServicesSection">Подключённые СТО</button>
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
<section class="drawer-section hidden" id="carsSection">
@@ -327,6 +330,33 @@
Конец периода
<input name="period_end" type="date" />
</label>
<label>
Месяцев покрытия
<select name="period_months">
<option value="">По датам</option>
<option value="1">1 месяц</option>
<option value="3">3 месяца</option>
<option value="6">6 месяцев</option>
<option value="12">12 месяцев</option>
</select>
</label>
<label>
Номер полиса / документа
<input name="policy_number" />
</label>
<label>
Тип страховки
<select name="insurance_type">
<option value="">Не задано</option>
<option value="mandatory">ОСАГО / обязательная</option>
<option value="full">КАСКО / полная</option>
<option value="other">Другое</option>
</select>
</label>
<label>
Комментарий
<input name="notes" />
</label>
<label class="check">
<input name="is_recurring" type="checkbox" />
Регулярный расход
@@ -465,11 +495,29 @@
Регистрационный номер
<input name="business_registration_number" />
</label>
<label>
Фото фасада, URL
<input name="facade_photo_url" placeholder="https://..." />
</label>
<label>
Фото документов, URL через запятую
<input name="document_photo_urls" placeholder="https://..., https://..." />
</label>
<label>
Дополнительные фото, URL через запятую
<input name="additional_photo_urls" placeholder="https://..., https://..." />
</label>
<button type="submit">Отправить заявку</button>
</form>
<div id="serviceCentersList" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="adminSection">
<h2>Модерация СТО</h2>
<div class="tip-card">Заявки видны только администраторам и модераторам.</div>
<div id="adminPendingServices" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="carFormSection">
<h2>Новое авто</h2>
<form id="carForm" class="grid-form drawer-form">
@@ -497,6 +545,18 @@
Год
<input name="year" type="number" min="1900" max="2100" />
</label>
<label>
Госномер
<input name="plate_number" placeholder="12가3456" />
</label>
<label>
VIN
<input name="vin" maxlength="17" placeholder="17 символов без I/O/Q" />
</label>
<label>
Текущий пробег
<input name="current_odometer" type="number" min="0" />
</label>
<label>
Тип топлива
<select name="fuel_type" id="fuelTypeSelect">
@@ -507,6 +567,24 @@
<option value="electric">Электро</option>
</select>
</label>
<label>
Стоимость покупки
<input name="purchase_price" type="number" min="0" step="0.01" />
</label>
<label>
Дата покупки
<input name="purchase_date" type="date" />
</label>
<label>
Тип покупки
<select name="purchase_type">
<option value="unknown">Не указано</option>
<option value="cash">Наличные</option>
<option value="credit">Кредит</option>
<option value="lease">Лизинг</option>
<option value="gift">Подарок</option>
</select>
</label>
<button type="submit">Добавить авто</button>
</form>
</section>
@@ -515,6 +593,45 @@
<h2>Параметры авто</h2>
<div class="tip-card" id="carProfileHint">Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.</div>
<form id="carProfileForm" class="grid-form drawer-form">
<label>
Госномер
<input name="plate_number" placeholder="12가3456" />
</label>
<label>
VIN
<input name="vin" maxlength="17" placeholder="17 символов без I/O/Q" />
</label>
<label>
Поколение / кузов
<input name="generation" placeholder="XV70 / CN7" />
</label>
<label>
Тип кузова
<input name="body_type" placeholder="седан / SUV" />
</label>
<label>
Объем двигателя, л
<input name="engine_volume_l" type="number" min="0" step="0.01" />
</label>
<label>
Коробка передач
<select name="transmission">
<option value="">Не задано</option>
<option value="manual">Механика</option>
<option value="automatic">Автомат</option>
<option value="cvt">CVT</option>
<option value="dct">DCT</option>
</select>
</label>
<label>
Привод
<select name="drive_type">
<option value="">Не задано</option>
<option value="fwd">Передний</option>
<option value="rwd">Задний</option>
<option value="awd">Полный</option>
</select>
</label>
<label>
Тип топлива
<select name="fuel_type">
@@ -565,6 +682,64 @@
Давление зад, bar
<input name="tire_pressure_rear_bar" type="number" min="0" step="0.01" placeholder="2.20" />
</label>
<label>
Размер шин
<input name="tire_size" placeholder="205/55 R16" />
</label>
<label>
Интервал масла, км
<input name="oil_change_interval_km" type="number" min="0" placeholder="10000" />
</label>
<label>
Интервал масла, мес
<input name="oil_change_interval_months" type="number" min="0" placeholder="12" />
</label>
<label>
Стоимость покупки
<input name="purchase_price" type="number" min="0" step="0.01" />
</label>
<label>
Дата покупки
<input name="purchase_date" type="date" />
</label>
<label>
Тип покупки
<select name="purchase_type">
<option value="unknown">Не указано</option>
<option value="cash">Наличные</option>
<option value="credit">Кредит</option>
<option value="lease">Лизинг</option>
<option value="gift">Подарок</option>
</select>
</label>
<label>
Сумма кредита
<input name="loan_principal" type="number" min="0" step="0.01" />
</label>
<label>
Первоначальный взнос
<input name="loan_down_payment" type="number" min="0" step="0.01" />
</label>
<label>
Срок кредита, мес
<input name="loan_term_months" type="number" min="1" />
</label>
<label>
Ставка годовая, %
<input name="loan_annual_interest_rate" type="number" min="0" step="0.001" />
</label>
<label>
Первый платеж
<input name="loan_first_payment_date" type="date" />
</label>
<label class="check">
<input name="include_depreciation" type="checkbox" />
Учитывать амортизацию
</label>
<label>
Заметки
<input name="notes" placeholder="Особенности авто" />
</label>
<button type="submit">Сохранить параметры</button>
</form>
</section>

View File

@@ -319,6 +319,9 @@ const state = {
analytics: null,
serviceCenters: [],
publicServiceCenters: [],
confirmations: null,
connectedServices: [],
adminPendingServices: [],
vehicleScore: null,
vehicleTimeline: [],
achievements: [],
@@ -512,6 +515,7 @@ async function ensureUser() {
body: JSON.stringify({ init_data: tg.initData }),
});
hideAuthOverlay();
updateRoleVisibility();
return;
}
if (state.authConfig?.allow_dev_auth) {
@@ -519,6 +523,7 @@ async function ensureUser() {
localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me");
hideAuthOverlay();
updateRoleVisibility();
return;
}
await showTelegramLogin();
@@ -530,6 +535,11 @@ function hideAuthOverlay() {
document.body.classList.remove("auth-required");
}
function updateRoleVisibility() {
const isAdmin = ["admin", "verifier", "moderator"].includes(state.user?.platform_role);
document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin));
}
function showTelegramOpenHint() {
const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot");
@@ -578,6 +588,7 @@ async function showTelegramLogin() {
});
localStorage.setItem("driversUser", JSON.stringify(state.user));
hideAuthOverlay();
updateRoleVisibility();
await loadCars();
};
const script = document.createElement("script");
@@ -799,7 +810,17 @@ function renderCars() {
}
function setInputValue(form, name, value) {
if (form?.elements[name]) form.elements[name].value = value ?? "";
if (!form?.elements[name]) return;
const input = form.elements[name];
if (input.type === "checkbox") {
input.checked = Boolean(value);
return;
}
input.value = value ?? "";
}
function csvList(value) {
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
}
function fillCarProfileForm() {
@@ -816,6 +837,13 @@ function fillCarProfileForm() {
}
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
[
"plate_number",
"vin",
"generation",
"body_type",
"engine_volume_l",
"transmission",
"drive_type",
"fuel_type",
"target_consumption_l_per_100km",
"fuel_tank_volume_l",
@@ -827,9 +855,168 @@ function fillCarProfileForm() {
"brake_fluid_type",
"tire_pressure_front_bar",
"tire_pressure_rear_bar",
"tire_size",
"oil_change_interval_km",
"oil_change_interval_months",
"purchase_price",
"purchase_date",
"purchase_type",
"loan_principal",
"loan_down_payment",
"loan_term_months",
"loan_annual_interest_rate",
"loan_first_payment_date",
"include_depreciation",
"notes",
].forEach((name) => setInputValue(form, name, car[name]));
}
async function loadConfirmations() {
const root = document.querySelector("#confirmationRequests");
if (!root) return;
try {
state.confirmations = await api("/my/confirmations");
const visits = state.confirmations.service_visits || [];
const changes = state.confirmations.change_requests || [];
const links = state.confirmations.service_links || [];
if (!visits.length && !changes.length && !links.length) {
root.innerHTML = `<div class="empty">Новых запросов нет</div>`;
return;
}
root.innerHTML = [
...visits.map((visit) => `
<div class="stack-item">
<strong>Визит СТО #${visit.id}</strong>
<small>${visit.visit_date} · ${visit.odometer || "-"} км · ${money(visit.total_cost || 0)}</small>
<div class="row-actions">
<button type="button" data-confirm-visit="${visit.id}">Подтвердить</button>
<button type="button" data-dispute-visit="${visit.id}">Спор</button>
</div>
</div>`),
...changes.map((item) => `
<div class="stack-item">
<strong>Изменение ${item.field_name}</strong>
<small>${item.old_value || "-"}${item.new_value || "-"}</small>
<div class="row-actions">
<button type="button" data-approve-change="${item.id}">Принять</button>
<button type="button" data-reject-change="${item.id}">Отклонить</button>
</div>
</div>`),
...links.map((link) => `
<div class="stack-item">
<strong>Запрос доступа от СТО #${link.service_center_id}</strong>
<small>Авто #${link.car_id} · ${link.access_level}</small>
<div class="row-actions">
<button type="button" data-approve-link="${link.id}">Разрешить</button>
<button type="button" data-revoke-link="${link.id}">Отклонить</button>
</div>
</div>`),
].join("");
bindConfirmationActions(root);
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить подтверждения</div>`;
}
}
function bindConfirmationActions(root) {
root.querySelectorAll("[data-confirm-visit]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/service-visits/${button.dataset.confirmVisit}/confirm`, { method: "POST" });
await loadConfirmations();
await loadSelectedCar();
}));
});
root.querySelectorAll("[data-dispute-visit]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отмечаю спор...", async () => {
await api(`/service-visits/${button.dataset.disputeVisit}/dispute`, { method: "POST" });
await loadConfirmations();
}));
});
root.querySelectorAll("[data-approve-change]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Применяю...", async () => {
await api(`/vehicle-change-requests/${button.dataset.approveChange}/approve`, { method: "POST" });
await loadConfirmations();
await loadCars();
}));
});
root.querySelectorAll("[data-reject-change]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/vehicle-change-requests/${button.dataset.rejectChange}/reject`, { method: "POST" });
await loadConfirmations();
}));
});
root.querySelectorAll("[data-approve-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Разрешаю доступ...", async () => {
await api(`/service-centers/links/${button.dataset.approveLink}/approve`, { method: "POST" });
await loadConfirmations();
await loadConnectedServices();
}));
});
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
await loadConfirmations();
}));
});
}
async function loadConnectedServices() {
const root = document.querySelector("#connectedServices");
if (!root) return;
try {
state.connectedServices = await api("/my/service-links");
root.innerHTML = state.connectedServices.length
? state.connectedServices.map((link) => `
<div class="stack-item">
<strong>${link.service_center_name}</strong>
<small>${link.car_name} · ${link.access_level} · ${link.status}</small>
${link.status === "approved" ? `<button type="button" data-revoke-link="${link.id}">Отозвать доступ</button>` : ""}
</div>`).join("")
: `<div class="empty">Подключенных автосервисов пока нет</div>`;
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отзываю доступ...", async () => {
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
await loadConnectedServices();
}));
});
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить подключения</div>`;
}
}
async function loadAdminPendingServices() {
const root = document.querySelector("#adminPendingServices");
if (!root) return;
try {
state.adminPendingServices = await api("/admin/service-centers/pending");
root.innerHTML = state.adminPendingServices.length
? state.adminPendingServices.map((center) => `
<div class="stack-item">
<strong>#${center.id} ${center.display_name || center.name}</strong>
<small>${[center.legal_name, center.city, center.address].filter(Boolean).join(" · ") || "Данные не заполнены"}</small>
<small>Документы: ${(center.document_photo_urls || []).length}</small>
<div class="row-actions">
<button type="button" data-admin-action="verify" data-admin-center="${center.id}">Одобрить</button>
<button type="button" data-admin-action="request-changes" data-admin-center="${center.id}">Правки</button>
<button type="button" data-admin-action="reject" data-admin-center="${center.id}">Отклонить</button>
</div>
</div>`).join("")
: `<div class="empty">Pending-заявок нет</div>`;
root.querySelectorAll("[data-admin-action]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю решение...", async () => {
const comment = button.dataset.adminAction === "verify" ? "Одобрено" : window.prompt("Комментарий для владельца СТО") || "";
await api(`/admin/service-centers/${button.dataset.adminCenter}/${button.dataset.adminAction}`, {
method: "POST",
body: JSON.stringify({ reason: comment, comment }),
});
await loadAdminPendingServices();
}));
});
} catch (error) {
root.innerHTML = `<div class="empty">Нет доступа или сервер не ответил</div>`;
}
}
function openCarProfile() {
openDrawerSection("carProfileSection");
}
@@ -1050,9 +1237,13 @@ function renderStats(stats) {
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
<button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button>
<button class="stat pop" data-report="summary"><span>Фиксированные</span><strong>${money(stats.fixed_costs || 0)}</strong><em>страховка, налоги, кредит</em></button>
<button class="stat pop" data-report="summary"><span>Переменные</span><strong>${money(stats.variable_costs || 0)}</strong><em>топливо, ремонт, услуги</em></button>
<button class="stat pop" data-report="summary"><span>Кредит</span><strong>${money((Number(stats.loan_principal_cost || 0) + Number(stats.loan_interest_cost || 0)))}</strong><em>тело и проценты</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
${stats.cost_warning ? `<div class="stat wide warning"><span>Предупреждение</span><strong>${stats.cost_warning}</strong><em>мягкая проверка расходов</em></div>` : ""}
`;
root.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => openReport(button.dataset.report));
@@ -1265,9 +1456,12 @@ function openReport(type = "summary") {
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")}
${reportMetric("Средний полный бак", analytics?.average_full_tank_distance ? `${analytics.average_full_tank_distance} км` : "-")}
${reportMetric("Средний бак", analytics?.average_cost_per_full_tank ? money(analytics.average_cost_per_full_tank) : "-")}
${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")}
${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")}
</div>
${analytics?.full_tank_warning ? `<div class="tip-card warning">${analytics.full_tank_warning}</div>` : ""}
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
`,
};
@@ -1589,7 +1783,15 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
model: data.model || null,
trim: data.trim || null,
year: data.year ? Number(data.year) : null,
plate_number: data.plate_number || null,
vin: data.vin || null,
current_odometer: numberOrNull(data.current_odometer),
fuel_type: data.fuel_type || null,
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
purchase_currency: state.user?.currency || "RUB",
currency: state.user?.currency || "RUB",
}),
});
form.reset();
@@ -1614,6 +1816,13 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even
const updated = await api(`/cars/${car.id}`, {
method: "PATCH",
body: JSON.stringify({
plate_number: data.plate_number || null,
vin: data.vin || null,
generation: data.generation || null,
body_type: data.body_type || null,
engine_volume_l: numberOrNull(data.engine_volume_l),
transmission: data.transmission || null,
drive_type: data.drive_type || null,
fuel_type: data.fuel_type || null,
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
@@ -1625,6 +1834,20 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even
brake_fluid_type: data.brake_fluid_type || null,
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
tire_size: data.tire_size || null,
oil_change_interval_km: numberOrNull(data.oil_change_interval_km),
oil_change_interval_months: numberOrNull(data.oil_change_interval_months),
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
include_depreciation: Boolean(data.include_depreciation),
loan_principal: numberOrNull(data.loan_principal),
loan_down_payment: numberOrNull(data.loan_down_payment),
loan_term_months: numberOrNull(data.loan_term_months),
loan_annual_interest_rate: numberOrNull(data.loan_annual_interest_rate),
loan_first_payment_date: data.loan_first_payment_date || null,
loan_currency: state.user?.currency || car.currency || "RUB",
notes: data.notes || null,
}),
});
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
@@ -1730,6 +1953,11 @@ document.querySelector("#expenseForm").addEventListener("submit", async (event)
odometer: numberOrNull(data.odometer),
period_start: data.period_start || null,
period_end: data.period_end || null,
period_months: numberOrNull(data.period_months),
payment_period_months: numberOrNull(data.period_months),
policy_number: data.policy_number || null,
insurance_type: data.insurance_type || null,
notes: data.notes || null,
is_recurring: Boolean(data.is_recurring),
}),
});
@@ -1792,11 +2020,12 @@ async function openDrawerSection(sectionId, options = {}) {
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
}
if (sectionId === "confirmationsSection") renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
if (sectionId === "connectedServicesSection") renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
if (sectionId === "confirmationsSection") await loadConfirmations();
if (sectionId === "connectedServicesSection") await loadConnectedServices();
if (sectionId === "servicePanelSection") await loadServiceCenters();
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) {
openDrawerSection("expensesSection");
presetExpense(options.expenseCategory);
@@ -1809,7 +2038,11 @@ function presetExpense(category) {
const form = document.querySelector("#expenseForm");
form.category.value = category;
form.title.value = expenseLabel(category);
if (category === "insurance") form.is_recurring.checked = true;
form.is_recurring.checked = category === "insurance" || category === "tax";
if (category === "insurance") {
form.period_months.value = "12";
form.insurance_type.value = "mandatory";
}
}
document.querySelectorAll("[data-action]").forEach((button) => {
@@ -1888,6 +2121,9 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
: null,
working_hours: data.working_hours || null,
business_registration_number: data.business_registration_number || null,
facade_photo_url: data.facade_photo_url || null,
document_photo_urls: csvList(data.document_photo_urls),
additional_photo_urls: csvList(data.additional_photo_urls),
}),
});
form.reset();

View File

@@ -659,7 +659,7 @@ h2 {
.grid-form,
.entry-form {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr 120px auto;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
@@ -793,6 +793,15 @@ select:disabled {
background: var(--soft);
}
.stat.wide {
grid-column: 1 / -1;
}
.warning {
border-color: rgba(210, 141, 38, 0.35);
background: #fff6e7;
}
.stat strong {
display: block;
margin-top: 6px;
@@ -1457,6 +1466,23 @@ select {
color: var(--muted);
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.row-actions button,
.stack-item > button {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff;
color: var(--text);
padding: 0 10px;
}
.trust-badge {
width: fit-content;
padding: 5px 8px;