diff --git a/app/api/service_centers.py b/app/api/service_centers.py
index 8c898ea..db3cf6c 100644
--- a/app/api/service_centers.py
+++ b/app/api/service_centers.py
@@ -393,6 +393,44 @@ async def request_vehicle_link(
return link
+@router.post("/{service_center_id}/vehicle-links/owner-attach", response_model=CarServiceLinkRead)
+async def owner_attach_vehicle_link(
+ service_center_id: int,
+ payload: ServiceCenterAccessRequest,
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_telegram_user),
+) -> CarServiceLink:
+ await ensure_service_center_approved(session, service_center_id)
+ vehicle = await session.get(Car, payload.car_id)
+ if vehicle is None:
+ raise HTTPException(status_code=404, detail="Vehicle not found")
+ if vehicle.owner_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Forbidden")
+ link = await upsert_service_link(
+ session,
+ car_id=payload.car_id,
+ service_center_id=service_center_id,
+ requested_by_user_id=current_user.id,
+ access_level=payload.access_level,
+ external_vehicle_ref=payload.external_vehicle_ref,
+ status_value="approved",
+ )
+ link.approved_by_user_id = current_user.id
+ link.approved_at = datetime.now(UTC)
+ link.revoked_at = None
+ await log_audit(
+ session,
+ actor=current_user,
+ action="car_service_link.owner_attach",
+ target_type="car_service_link",
+ target_id=link.id,
+ metadata={"car_id": payload.car_id, "service_center_id": service_center_id},
+ )
+ await session.commit()
+ await session.refresh(link)
+ return link
+
+
@router.post("/links/{link_id}/approve", response_model=CarServiceLinkRead)
async def approve_vehicle_link(
link_id: int,
diff --git a/tests/test_platform.py b/tests/test_platform.py
index bc558c3..67b4384 100644
--- a/tests/test_platform.py
+++ b/tests/test_platform.py
@@ -139,6 +139,21 @@ async def test_public_service_center_and_review_flow(
refreshed = await client.get(f"/api/service-centers/{center['id']}", headers=auth_headers)
assert refreshed.json()["reviews_count"] == 1
+ vehicle = (
+ await client.post(
+ "/api/my/vehicles",
+ headers=auth_headers,
+ json={"name": "Review car"},
+ )
+ ).json()
+ link = await client.post(
+ f"/api/service-centers/{center['id']}/vehicle-links/owner-attach",
+ headers=auth_headers,
+ json={"car_id": vehicle["id"], "access_level": "basic"},
+ )
+ assert link.status_code == 200
+ assert link.json()["status"] == "approved"
+
@pytest.mark.asyncio
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
diff --git a/web/index.html b/web/index.html
index 1d30f62..f0d27bc 100644
--- a/web/index.html
+++ b/web/index.html
@@ -367,6 +367,7 @@
Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.
+
diff --git a/web/static/app.js b/web/static/app.js
index 7e68c70..824b073 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -318,6 +318,7 @@ const state = {
allStats: null,
analytics: null,
serviceCenters: [],
+ publicServiceCenters: [],
vehicleScore: null,
vehicleTimeline: [],
achievements: [],
@@ -873,20 +874,24 @@ async function loadPublicServiceCenters() {
if (!root) return;
try {
const centers = await api("/service-centers/public");
+ state.publicServiceCenters = centers;
root.innerHTML = centers.length
? centers
.map(
(center) => `
-
+
+
`,
)
.join("")
: `Проверенных СТО пока нет
`;
+ root.querySelectorAll("[data-service-card]").forEach((button) => {
+ button.addEventListener("click", () => openServiceCard(Number(button.dataset.serviceCard)));
+ });
} catch (error) {
root.innerHTML = `Не удалось загрузить СТО
`;
}
@@ -895,7 +900,102 @@ async function loadPublicServiceCenters() {
function renderServiceReviews() {
const root = document.querySelector("#serviceReviews");
if (!root) return;
- root.innerHTML = `Отзывы доступны в карточке проверенного СТО. Выберите сервис в разделе «СТО».
`;
+ root.innerHTML = state.publicServiceCenters.length
+ ? state.publicServiceCenters
+ .map((center) => ``)
+ .join("")
+ : `Откройте раздел «СТО», чтобы загрузить проверенные сервисы.
`;
+ root.querySelectorAll("[data-service-card]").forEach((button) => {
+ button.addEventListener("click", async () => {
+ await openDrawerSection("publicServicesSection");
+ await openServiceCard(Number(button.dataset.serviceCard));
+ });
+ });
+}
+
+async function openServiceCard(serviceCenterId) {
+ const card = document.querySelector("#serviceCard");
+ if (!card) return;
+ const [center, reviews] = await Promise.all([
+ api(`/service-centers/${serviceCenterId}`),
+ api(`/service-centers/${serviceCenterId}/reviews?limit=20`),
+ ]);
+ card.classList.remove("hidden");
+ card.innerHTML = `
+
+
+
СТО
+
${center.display_name || center.name}
+
+
${center.rating_avg ? `★ ${center.rating_avg} · ${center.reviews_count}` : "Проверенный сервис"}
+
+
+ ${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}
+ ${center.phone || "Телефон не указан"}
+ ${center.description || "Описание появится после заполнения карточки сервисом."}
+
+
+
+
+
+
+ ${reviews.length
+ ? reviews.map((review) => `
+
+ ★ ${review.rating}
+ ${review.text || "Без текста"}
+ ${review.service_response ? `Ответ СТО: ${review.service_response}` : ""}
+
+ `).join("")
+ : `
Отзывов еще нет
`}
+
+ `;
+ card.querySelector("#serviceReviewForm").addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget;
+ await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
+ const data = formData(form);
+ await api(`/service-centers/${serviceCenterId}/reviews`, {
+ method: "POST",
+ body: JSON.stringify({ rating: Number(data.rating), text: data.text || null }),
+ });
+ await openServiceCard(serviceCenterId);
+ await loadPublicServiceCenters();
+ toast("Сохранено");
+ haptic("success");
+ });
+ });
+ card.querySelector("#attachServiceBtn").addEventListener("click", async (event) => {
+ if (!state.selectedCarId) {
+ toast("Выбери автомобиль", "error");
+ return;
+ }
+ await runAction(event.currentTarget, "Сохраняю...", async () => {
+ await api(`/service-centers/${serviceCenterId}/vehicle-links/owner-attach`, {
+ method: "POST",
+ body: JSON.stringify({ car_id: state.selectedCarId, access_level: "basic" }),
+ });
+ toast("Авто привязано к СТО");
+ haptic("success");
+ });
+ });
+ card.scrollIntoView({ behavior: "smooth", block: "start" });
}
function trustLabel(level) {
diff --git a/web/static/styles.css b/web/static/styles.css
index f00e8a0..2173108 100644
--- a/web/static/styles.css
+++ b/web/static/styles.css
@@ -1167,6 +1167,39 @@ button.is-busy {
overflow: auto;
}
+.service-list-card,
+.service-card {
+ width: 100%;
+ text-align: left;
+}
+
+.service-list-card {
+ display: grid;
+ gap: 4px;
+ min-height: auto;
+ padding: 12px;
+ color: var(--text);
+ background: var(--soft);
+ border: 1px solid var(--line);
+ box-shadow: none;
+}
+
+.service-card {
+ display: grid;
+ gap: 12px;
+ margin-top: 12px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+}
+
+.service-actions {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 8px;
+}
+
.summary-card::after {
display: none;
}