onRowClick(row) : undefined}
+ >
{columns.map((column) => (
{column.render(row)}
diff --git a/apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx b/apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx
new file mode 100644
index 000000000..4966c1c72
--- /dev/null
+++ b/apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx
@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+import { ActivityLog } from "../ActivityLog";
+import type { StoQueueVehicle } from "../../types/stoVehicle";
+import { buildVehicleActivityLog } from "../../utils/stoVehicleActivityLog";
+
+interface StoVehicleActivityLogProps {
+ vehicle: StoQueueVehicle;
+}
+
+export function StoVehicleActivityLog({ vehicle }: StoVehicleActivityLogProps) {
+ const entries = useMemo(() => buildVehicleActivityLog(vehicle), [vehicle]);
+
+ return (
+
+ );
+}
diff --git a/apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts b/apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts
new file mode 100644
index 000000000..74ffad021
--- /dev/null
+++ b/apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts
@@ -0,0 +1 @@
+export { StoVehicleActivityLog } from "./StoVehicleActivityLog";
diff --git a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css
index 25b3e8f17..9da852ae6 100644
--- a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css
+++ b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css
@@ -272,7 +272,7 @@
.stepPanel {
display: flex;
- margin: 20px 24px 24px;
+ margin: 20px 24px 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
overflow: hidden;
diff --git a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx
index c1e6bb046..9cf2d52d4 100644
--- a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx
+++ b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ApprovalRequestCard } from "../ApprovalRequestCard";
import { Button } from "../Button";
import { Modal } from "../Modal";
+import { StoVehicleActivityLog } from "../StoVehicleActivityLog";
import { StoVehicleStatusBadge } from "../StoVehicleStatusBadge";
import type {
StoQueueVehicle,
@@ -539,6 +540,8 @@ export function StoVehicleDetailModal({
) : null}
+
+
);
diff --git a/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css
new file mode 100644
index 000000000..170304772
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css
@@ -0,0 +1,248 @@
+.modalContent {
+ display: flex;
+ flex-direction: column;
+ min-height: 420px;
+}
+
+.topBar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 24px 24px 20px;
+ border-bottom: 1px solid var(--border-secondary);
+ background: linear-gradient(
+ 180deg,
+ rgba(74, 222, 158, 0.06) 0%,
+ transparent 100%
+ );
+}
+
+.topBarMain {
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+ min-width: 0;
+}
+
+.topBarIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ flex-shrink: 0;
+ border-radius: var(--radius-md);
+ background-color: rgba(74, 222, 158, 0.12);
+ color: var(--accent-primary);
+}
+
+.topBarIcon svg {
+ width: 26px;
+ height: 26px;
+}
+
+.topBarTitle {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.topBarMeta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.plateBadge {
+ padding: 4px 10px;
+ background-color: #1a4038;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-primary);
+}
+
+.closeButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-sm);
+ background-color: var(--bg-tertiary);
+ color: var(--text-secondary);
+ font-size: 24px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.closeButton:hover {
+ color: var(--text-primary);
+ background-color: var(--bg-quaternary);
+}
+
+.summaryGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--border-secondary);
+}
+
+.summaryCard {
+ padding: 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.summaryLabel {
+ margin: 0 0 12px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ color: var(--accent-primary);
+}
+
+.summaryList {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin: 0;
+}
+
+.summaryRow {
+ display: grid;
+ grid-template-columns: 110px 1fr;
+ gap: 12px;
+}
+
+.summaryRow dt {
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+.summaryRow dd {
+ margin: 0;
+ font-size: 14px;
+ color: var(--text-primary);
+ line-height: 1.4;
+}
+
+.recordsSection {
+ padding: 20px 24px 0;
+}
+
+.sectionTitle {
+ margin: 0 0 14px;
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+}
+
+.recordsPanel {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background-color: color-mix(in srgb, var(--accent-primary) 4%, #1a1c1e);
+}
+
+.recordsPanelAccent {
+ height: 4px;
+ background-color: var(--accent-primary);
+}
+
+.recordsList {
+ display: flex;
+ flex-direction: column;
+}
+
+.recordRow {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 18px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.recordRow:last-child {
+ border-bottom: none;
+}
+
+.recordTitle {
+ margin: 0 0 4px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.recordDescription {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.45;
+}
+
+.recordPrice {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--accent-primary);
+ white-space: nowrap;
+}
+
+.totalRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px 18px;
+ background-color: rgba(74, 222, 158, 0.06);
+ border-top: 1px solid rgba(74, 222, 158, 0.16);
+}
+
+.totalLabel {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.totalValue {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--accent-primary);
+}
+
+.emptyRecords {
+ padding: 24px 18px;
+ font-size: 13px;
+ color: var(--text-tertiary);
+ text-align: center;
+}
+
+@media (max-width: 700px) {
+ .summaryGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .summaryRow {
+ grid-template-columns: 1fr;
+ gap: 4px;
+ }
+
+ .recordRow {
+ flex-direction: column;
+ gap: 8px;
+ }
+}
diff --git a/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx
new file mode 100644
index 000000000..34d582da9
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx
@@ -0,0 +1,161 @@
+import { useMemo } from "react";
+import { Modal } from "../Modal";
+import { ActivityLog } from "../ActivityLog";
+import { VisitStatusBadge } from "../VisitStatusBadge";
+import type { VisitListItem } from "../../types/visit";
+import { buildVisitActivityLog } from "../../utils/visitActivityLog";
+import {
+ calcVisitTotal,
+ formatVisitDateTime,
+ formatVisitPrice,
+ formatVisitTitle,
+} from "../../utils/visitLabels";
+import styles from "./VisitDetailModal.module.css";
+
+interface VisitDetailModalProps {
+ visit: VisitListItem | null;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function VisitDetailModal({ visit, isOpen, onClose }: VisitDetailModalProps) {
+ const activityEntries = useMemo(
+ () => (visit ? buildVisitActivityLog(visit) : []),
+ [visit],
+ );
+
+ if (!visit) return null;
+
+ const vehicleTitle = formatVisitTitle(visit.brand, visit.model, visit.year);
+ const total = calcVisitTotal(visit.records);
+
+ return (
+
+
+
+
+
+
+ {visit.title}
+
+ {visit.licensePlate}
+
+
+
+
+
+
+
+
+
+ Візит
+
+
+ - СТО
+ - {visit.stoName}
+
+
+ - Механік
+ - {visit.mechanicName ?? "Не призначено"}
+
+
+ - Початок
+ - {formatVisitDateTime(visit.createdAt)}
+
+ {visit.completedAt ? (
+
+ - Завершено
+ - {formatVisitDateTime(visit.completedAt)}
+
+ ) : visit.updatedAt ? (
+
+ - Оновлено
+ - {formatVisitDateTime(visit.updatedAt)}
+
+ ) : null}
+ {visit.description ? (
+
+ - Опис
+ - {visit.description}
+
+ ) : null}
+
+
+
+
+ Авто та клієнт
+
+
+ - Автомобіль
+ - {vehicleTitle}
+
+
+ - Номер
+ - {visit.licensePlate}
+
+
+ - Клієнт
+ - {visit.clientName}
+
+
+ - Телефон
+ - {visit.clientPhone ?? "—"}
+
+
+
+
+
+
+
+ Роботи та деталі ({visit.records.length})
+
+
+
+ {visit.records.length > 0 ? (
+ <>
+
+ {visit.records.map((record) => (
+
+
+ {record.title}
+ {record.description ? (
+ {record.description}
+ ) : null}
+
+ {formatVisitPrice(record.price)}
+
+ ))}
+
+
+ Загальна сума
+ {formatVisitPrice(total)}
+
+ >
+ ) : (
+ Список робіт порожній.
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/AdminWebUi/src/components/VisitDetailModal/index.ts b/apps/AdminWebUi/src/components/VisitDetailModal/index.ts
new file mode 100644
index 000000000..08418336d
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitDetailModal/index.ts
@@ -0,0 +1 @@
+export { VisitDetailModal } from "./VisitDetailModal";
diff --git a/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css
new file mode 100644
index 000000000..b1f4230e0
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css
@@ -0,0 +1,18 @@
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ border: 1px solid transparent;
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
diff --git a/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx
new file mode 100644
index 000000000..1d6b8093f
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx
@@ -0,0 +1,28 @@
+import type { VisitStatus } from "../../types/visit";
+import { getVisitStatusBadgeStyle, VISIT_STATUS_LABELS } from "../../utils/visitLabels";
+import styles from "./VisitStatusBadge.module.css";
+
+interface VisitStatusBadgeProps {
+ status: VisitStatus;
+ showDot?: boolean;
+}
+
+export function VisitStatusBadge({ status, showDot = true }: VisitStatusBadgeProps) {
+ const badgeStyle = getVisitStatusBadgeStyle(status);
+
+ return (
+
+ {showDot ? (
+
+ ) : null}
+ {VISIT_STATUS_LABELS[status]}
+
+ );
+}
diff --git a/apps/AdminWebUi/src/components/VisitStatusBadge/index.ts b/apps/AdminWebUi/src/components/VisitStatusBadge/index.ts
new file mode 100644
index 000000000..15a25ace4
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitStatusBadge/index.ts
@@ -0,0 +1 @@
+export { VisitStatusBadge } from "./VisitStatusBadge";
diff --git a/apps/AdminWebUi/src/config/navigation.ts b/apps/AdminWebUi/src/config/navigation.ts
index 1dc1b9f00..eda650a69 100644
--- a/apps/AdminWebUi/src/config/navigation.ts
+++ b/apps/AdminWebUi/src/config/navigation.ts
@@ -27,6 +27,20 @@ export const NAV_ITEMS: NavItemConfig[] = [
icon: "M4 6h16v2h2v10H6V8H4V6zm4 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
requiredPermissions: [Permission.StoView],
},
+ {
+ id: "booking-requests",
+ label: "Онлайн-записи",
+ path: "/sto/bookings",
+ icon: "M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V9h14v11zM7 11h5v2H7v-2zm7 0h5v2h-5v-2z",
+ requiredPermissions: [Permission.StoView],
+ },
+ {
+ id: "visits",
+ label: "Візити",
+ path: "/visits",
+ icon: "M4 4h16v2H4V4zm0 4h10v2H4V8zm0 4h14v2H4v-2zm0 4h8v2H4v-2z",
+ requiredPermissions: [Permission.StoView],
+ },
{
id: "users",
label: "Клієнти",
diff --git a/apps/AdminWebUi/src/context/BookingRequestContext.tsx b/apps/AdminWebUi/src/context/BookingRequestContext.tsx
new file mode 100644
index 000000000..f97775c2f
--- /dev/null
+++ b/apps/AdminWebUi/src/context/BookingRequestContext.tsx
@@ -0,0 +1,119 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import { MOCK_BOOKING_REQUESTS } from "../data/mockBookingRequests";
+import { useStoVehicles } from "./StoVehicleContext";
+import type { BookingRequest, BookingRequestStatus } from "../types/bookingRequest";
+import { buildProblemSummary } from "../utils/bookingLabels";
+
+interface BookingRequestContextValue {
+ requests: BookingRequest[];
+ getRequestsBySto: (stoId: string, status?: BookingRequestStatus) => BookingRequest[];
+ confirmRequest: (
+ requestId: string,
+ appointmentAt: string,
+ managerNote?: string,
+ ) => BookingRequest | null;
+ rejectRequest: (requestId: string, reason: string) => void;
+}
+
+const BookingRequestContext = createContext(null);
+
+export function BookingRequestProvider({ children }: { children: ReactNode }) {
+ const { addVehicle } = useStoVehicles();
+ const [requests, setRequests] = useState(
+ () => [...MOCK_BOOKING_REQUESTS],
+ );
+
+ const getRequestsBySto = useCallback(
+ (stoId: string, status?: BookingRequestStatus) => {
+ const filtered = requests.filter((request) => request.stoId === stoId);
+ if (!status) return filtered;
+ return filtered.filter((request) => request.status === status);
+ },
+ [requests],
+ );
+
+ const confirmRequest = useCallback(
+ (requestId: string, appointmentAt: string, managerNote?: string) => {
+ const request = requests.find((item) => item.id === requestId);
+ if (!request || request.status !== "pending") return null;
+
+ const vehicle = addVehicle({
+ stoId: request.stoId,
+ brand: request.brand,
+ model: request.model,
+ year: request.year,
+ licensePlate: request.licensePlate,
+ clientName: request.clientName,
+ clientPhone: request.clientPhone,
+ problemSummary: buildProblemSummary(request.visitReason, request.problemDescription),
+ status: "scheduled",
+ appointmentAt,
+ isManualEntry: false,
+ });
+
+ const now = new Date().toISOString();
+ const updated: BookingRequest = {
+ ...request,
+ status: "confirmed",
+ managerAppointmentAt: appointmentAt,
+ managerNote: managerNote?.trim() || undefined,
+ queueVehicleId: vehicle.id,
+ updatedAt: now,
+ };
+
+ setRequests((current) =>
+ current.map((item) => (item.id === requestId ? updated : item)),
+ );
+
+ return updated;
+ },
+ [requests, addVehicle],
+ );
+
+ const rejectRequest = useCallback((requestId: string, reason: string) => {
+ const now = new Date().toISOString();
+ setRequests((current) =>
+ current.map((request) =>
+ request.id === requestId && request.status === "pending"
+ ? {
+ ...request,
+ status: "rejected",
+ rejectionReason: reason.trim(),
+ updatedAt: now,
+ }
+ : request,
+ ),
+ );
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ requests,
+ getRequestsBySto,
+ confirmRequest,
+ rejectRequest,
+ }),
+ [requests, getRequestsBySto, confirmRequest, rejectRequest],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useBookingRequests(): BookingRequestContextValue {
+ const context = useContext(BookingRequestContext);
+ if (!context) {
+ throw new Error("useBookingRequests must be used within BookingRequestProvider");
+ }
+ return context;
+}
diff --git a/apps/AdminWebUi/src/context/StoVehicleContext.tsx b/apps/AdminWebUi/src/context/StoVehicleContext.tsx
index 21e24efa2..0e2f75bc2 100644
--- a/apps/AdminWebUi/src/context/StoVehicleContext.tsx
+++ b/apps/AdminWebUi/src/context/StoVehicleContext.tsx
@@ -86,7 +86,7 @@ export function StoVehicleProvider({ children }: { children: ReactNode }) {
? 100
: undefined,
approvalRequests: [],
- isManualEntry: true,
+ isManualEntry: payload.isManualEntry ?? true,
createdAt: now,
updatedAt: now,
};
diff --git a/apps/AdminWebUi/src/data/mockBookingRequests.ts b/apps/AdminWebUi/src/data/mockBookingRequests.ts
new file mode 100644
index 000000000..b241c9b98
--- /dev/null
+++ b/apps/AdminWebUi/src/data/mockBookingRequests.ts
@@ -0,0 +1,124 @@
+import type { BookingRequest } from "../types/bookingRequest";
+
+export const MOCK_BOOKING_REQUESTS: BookingRequest[] = [
+ {
+ id: "br-001",
+ stoId: "sto-001",
+ vehicleId: "veh-m-001",
+ driverId: "drv-001",
+ brand: "Toyota",
+ model: "Camry",
+ year: 2019,
+ licensePlate: "AA 1234 BC",
+ vin: "JTDBR32E720123456",
+ clientName: "Олександр Коваленко",
+ clientPhone: "+380 67 111 2233",
+ visitReason: "suspension",
+ problemDescription: "Стукає справа попереду на нерівностях, веде вліво на швидкості.",
+ preferredDate: "2026-07-05",
+ preferredTimeSlot: "morning",
+ partsOption: "shop",
+ attachmentsCount: 2,
+ status: "pending",
+ createdAt: "2026-07-01T08:20:00.000Z",
+ updatedAt: "2026-07-01T08:20:00.000Z",
+ },
+ {
+ id: "br-002",
+ stoId: "sto-001",
+ vehicleId: "veh-m-002",
+ driverId: "drv-002",
+ brand: "Volkswagen",
+ model: "Passat",
+ year: 2017,
+ licensePlate: "KA 5678 AB",
+ vin: "WVWZZZ3CZHE123789",
+ clientName: "Марія Петренко",
+ clientPhone: "+380 50 444 5566",
+ visitReason: "maintenance",
+ problemDescription: "Планове ТО, пробіг 95 000 км. Потрібна заміна масла та фільтрів.",
+ preferredDate: "2026-07-04",
+ preferredTimeSlot: "day",
+ partsOption: "shop",
+ attachmentsCount: 0,
+ status: "pending",
+ createdAt: "2026-07-01T10:45:00.000Z",
+ updatedAt: "2026-07-01T10:45:00.000Z",
+ },
+ {
+ id: "br-003",
+ stoId: "sto-001",
+ brand: "BMW",
+ model: "X5",
+ year: 2021,
+ licensePlate: "BC 9012 DE",
+ clientName: "Ігор Сидоренко",
+ clientPhone: "+380 63 777 8899",
+ visitReason: "diagnostics",
+ problemDescription: "Горить Check Engine, інколи троїть на холостих. Потрібна комп'ютерна діагностика.",
+ preferredDate: "2026-07-06",
+ preferredTimeSlot: "evening",
+ partsOption: "own",
+ attachmentsCount: 1,
+ status: "pending",
+ createdAt: "2026-07-01T14:10:00.000Z",
+ updatedAt: "2026-07-01T14:10:00.000Z",
+ },
+ {
+ id: "br-004",
+ stoId: "sto-002",
+ brand: "Audi",
+ model: "A4",
+ year: 2020,
+ licensePlate: "BC 7788 PQ",
+ clientName: "Сергій Ткаченко",
+ clientPhone: "+380 96 555 4433",
+ visitReason: "other",
+ problemDescription: "Після ДТП не закривається багажник, потрібна оцінка кузовних робіт.",
+ preferredDate: "2026-07-07",
+ preferredTimeSlot: "morning",
+ partsOption: "shop",
+ attachmentsCount: 3,
+ status: "pending",
+ createdAt: "2026-07-01T16:30:00.000Z",
+ updatedAt: "2026-07-01T16:30:00.000Z",
+ },
+ {
+ id: "br-005",
+ stoId: "sto-001",
+ brand: "Hyundai",
+ model: "Tucson",
+ year: 2020,
+ licensePlate: "AI 3456 FG",
+ clientName: "Анна Мельник",
+ visitReason: "maintenance",
+ problemDescription: "Заміна гальмівних колодок, скрипить при гальмуванні.",
+ preferredDate: "2026-06-28",
+ preferredTimeSlot: "day",
+ partsOption: "shop",
+ status: "confirmed",
+ managerAppointmentAt: "2026-06-29T09:10:00.000Z",
+ managerNote: "Запис підтверджено телефоном",
+ queueVehicleId: "sv-004",
+ createdAt: "2026-06-27T18:00:00.000Z",
+ updatedAt: "2026-06-28T09:00:00.000Z",
+ },
+ {
+ id: "br-006",
+ stoId: "sto-001",
+ brand: "Ford",
+ model: "Focus",
+ year: 2015,
+ licensePlate: "AA 5566 NO",
+ clientName: "Катерина Лисенко",
+ visitReason: "diagnostics",
+ problemDescription: "Не працює кондиціонер, потрібна діагностика.",
+ preferredDate: "2026-06-30",
+ preferredTimeSlot: "evening",
+ partsOption: "own",
+ status: "rejected",
+ rejectionReason: "Немає вільного вікна у зазначений час. Запропоновано іншу дату — клієнт відмовився.",
+ createdAt: "2026-06-29T11:00:00.000Z",
+ updatedAt: "2026-06-29T15:20:00.000Z",
+ },
+];
diff --git a/apps/AdminWebUi/src/data/mockVisits.ts b/apps/AdminWebUi/src/data/mockVisits.ts
new file mode 100644
index 000000000..19fc70e10
--- /dev/null
+++ b/apps/AdminWebUi/src/data/mockVisits.ts
@@ -0,0 +1,209 @@
+import type { VisitListItem } from "../types/visit";
+
+export const MOCK_VISITS: VisitListItem[] = [
+ {
+ id: "visit-001",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-001",
+ brand: "Mercedes-Benz",
+ model: "E-Class",
+ year: 2018,
+ licensePlate: "AA 7890 HI",
+ clientName: "Віктор Бондаренко",
+ clientPhone: "+380 97 222 3344",
+ title: "Ремонт кондиціонера",
+ description: "Діагностика та заміна компресора кондиціонера, вакуумування системи.",
+ status: "in_progress",
+ mechanicName: "Петро Механік",
+ records: [
+ { id: "vr-001-1", title: "Діагностика системи кондиціонування", price: 800 },
+ { id: "vr-001-2", title: "Заміна компресора", price: 3200 },
+ { id: "vr-001-3", title: "Вакуумування та заправка фреоном", price: 1400 },
+ ],
+ createdAt: "2026-06-28T11:00:00.000Z",
+ updatedAt: "2026-06-29T07:30:00.000Z",
+ },
+ {
+ id: "visit-002",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-002",
+ brand: "Mazda",
+ model: "CX-5",
+ year: 2018,
+ licensePlate: "KA 4455 RS",
+ clientName: "Юрій Романюк",
+ clientPhone: "+380 68 333 4455",
+ title: "Заміна ГРМ",
+ description: "Заміна комплекту ГРМ, помпи охолодження та усунення витоку ОР.",
+ status: "in_progress",
+ mechanicName: "Андрій Сервіс",
+ records: [
+ { id: "vr-002-1", title: "Зняття/встановлення ГРМ", price: 4500 },
+ { id: "vr-002-2", title: "Заміна помпи охолодження", price: 2800 },
+ { id: "vr-002-3", title: "Промивка системи охолодження", price: 1800 },
+ { id: "vr-002-4", title: "Комплект ГРМ (деталі)", price: 7200 },
+ { id: "vr-002-5", title: "Помпа охолодження (деталь)", price: 3400 },
+ ],
+ createdAt: "2026-06-27T09:30:00.000Z",
+ updatedAt: "2026-06-29T10:20:00.000Z",
+ },
+ {
+ id: "visit-003",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-003",
+ brand: "BMW",
+ model: "X5",
+ year: 2021,
+ licensePlate: "BC 9012 DE",
+ clientName: "Ігор Сидоренко",
+ clientPhone: "+380 63 777 8899",
+ title: "Діагностика двигуна",
+ description: "Комп'ютерна діагностика, зчитування помилок Check Engine.",
+ status: "waiting_payment",
+ mechanicName: "Олег Діагност",
+ records: [
+ { id: "vr-003-1", title: "Комп'ютерна діагностика", price: 900 },
+ { id: "vr-003-2", title: "Заміна свічок запалення", price: 2200 },
+ { id: "vr-003-3", title: "Діагностика котушок запалювання", price: 800 },
+ ],
+ createdAt: "2026-06-26T12:00:00.000Z",
+ updatedAt: "2026-06-29T09:20:00.000Z",
+ },
+ {
+ id: "visit-004",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-004",
+ brand: "Ford",
+ model: "Focus",
+ year: 2015,
+ licensePlate: "AA 5566 NO",
+ clientName: "Катерина Лисенко",
+ clientPhone: "+380 93 888 9900",
+ title: "Заміна акумулятора",
+ description: "Заміна акумулятора, перевірка системи зарядки.",
+ status: "completed",
+ mechanicName: "Петро Механік",
+ records: [
+ { id: "vr-004-1", title: "Діагностика електросистеми", price: 500 },
+ { id: "vr-004-2", title: "Заміна акумулятора", price: 4200 },
+ ],
+ createdAt: "2026-06-26T16:00:00.000Z",
+ updatedAt: "2026-06-29T09:45:00.000Z",
+ completedAt: "2026-06-29T09:45:00.000Z",
+ },
+ {
+ id: "visit-005",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-005",
+ brand: "Renault",
+ model: "Duster",
+ year: 2019,
+ licensePlate: "AB 3344 LM",
+ clientName: "Дмитро Гриценко",
+ title: "ТО-2",
+ description: "Планове технічне обслуговування: заміна масла, фільтрів, перевірка ходової.",
+ status: "completed",
+ records: [
+ { id: "vr-005-1", title: "Заміна моторної оливи", price: 1200 },
+ { id: "vr-005-2", title: "Заміна масляного фільтра", price: 350 },
+ { id: "vr-005-3", title: "Заміна повітряного фільтра", price: 450 },
+ { id: "vr-005-4", title: "Заміна салонного фільтра", price: 380 },
+ { id: "vr-005-5", title: "Перевірка ходової частини", price: 600 },
+ ],
+ createdAt: "2026-06-23T11:00:00.000Z",
+ updatedAt: "2026-06-29T10:00:00.000Z",
+ completedAt: "2026-06-29T10:00:00.000Z",
+ },
+ {
+ id: "visit-006",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-006",
+ brand: "Skoda",
+ model: "Octavia",
+ year: 2016,
+ licensePlate: "KA 1122 JK",
+ clientName: "Олена Шевченко",
+ clientPhone: "+380 66 555 6677",
+ title: "Заміна зчеплення",
+ description: "Заміна комплекту зчеплення, перевірка маховика.",
+ status: "in_progress",
+ mechanicName: "Андрій Сервіс",
+ records: [
+ { id: "vr-006-1", title: "Зняття КПП", price: 3800 },
+ { id: "vr-006-2", title: "Заміна комплекту зчеплення", price: 5200 },
+ { id: "vr-006-3", title: "Комплект зчеплення (деталі)", price: 8900 },
+ ],
+ createdAt: "2026-06-24T14:00:00.000Z",
+ updatedAt: "2026-06-29T06:00:00.000Z",
+ },
+ {
+ id: "visit-007",
+ stoId: "sto-002",
+ stoName: "ProMotors Lviv",
+ vehicleId: "veh-007",
+ brand: "Audi",
+ model: "A4",
+ year: 2020,
+ licensePlate: "BC 7788 PQ",
+ clientName: "Сергій Ткаченко",
+ title: "Діагностика ходової",
+ description: "Перевірка підвіски, рульового управління та гальмівної системи.",
+ status: "in_progress",
+ mechanicName: "Михайло Львів",
+ records: [
+ { id: "vr-007-1", title: "Діагностика ходової частини", price: 700 },
+ { id: "vr-007-2", title: "Перевірка гальмівних механізмів", price: 500 },
+ ],
+ createdAt: "2026-06-28T17:00:00.000Z",
+ updatedAt: "2026-06-29T10:00:00.000Z",
+ },
+ {
+ id: "visit-008",
+ stoId: "sto-002",
+ stoName: "ProMotors Lviv",
+ vehicleId: "veh-008",
+ brand: "Toyota",
+ model: "Camry",
+ year: 2019,
+ licensePlate: "AA 1234 BC",
+ clientName: "Олександр Коваленко",
+ clientPhone: "+380 67 111 2233",
+ title: "Перевірка підвіски",
+ description: "Клієнт скасував візит після попередньої оцінки вартості.",
+ status: "cancelled",
+ records: [
+ { id: "vr-008-1", title: "Огляд підвіски", price: 600 },
+ ],
+ createdAt: "2026-06-28T10:00:00.000Z",
+ updatedAt: "2026-06-28T14:30:00.000Z",
+ },
+ {
+ id: "visit-009",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-009",
+ brand: "Hyundai",
+ model: "Tucson",
+ year: 2020,
+ licensePlate: "AI 3456 FG",
+ clientName: "Анна Мельник",
+ title: "Заміна гальмівних колодок",
+ description: "Заміна передніх і задніх гальмівних колодок.",
+ status: "waiting_payment",
+ mechanicName: "Петро Механік",
+ records: [
+ { id: "vr-009-1", title: "Заміна передніх колодок", price: 1600 },
+ { id: "vr-009-2", title: "Заміна задніх колодок", price: 1400 },
+ { id: "vr-009-3", title: "Передні колодки (деталі)", price: 2100 },
+ { id: "vr-009-4", title: "Задні колодки (деталі)", price: 1800 },
+ ],
+ createdAt: "2026-06-28T08:30:00.000Z",
+ updatedAt: "2026-06-29T09:10:00.000Z",
+ },
+];
diff --git a/apps/AdminWebUi/src/layout/Sidebar/Sidebar.tsx b/apps/AdminWebUi/src/layout/Sidebar/Sidebar.tsx
index 21cea89a2..2a811cdfe 100644
--- a/apps/AdminWebUi/src/layout/Sidebar/Sidebar.tsx
+++ b/apps/AdminWebUi/src/layout/Sidebar/Sidebar.tsx
@@ -26,7 +26,7 @@ export function Sidebar({ isCollapsed, onToggleCollapse }: SidebarProps) {
C
- CaRaMob
+ Rookit
Admin Panel
diff --git a/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css
new file mode 100644
index 000000000..16030029c
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css
@@ -0,0 +1,175 @@
+.page {
+ width: 100%;
+ max-width: none;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 20px;
+}
+
+.headerText {
+ flex: 1;
+ min-width: 0;
+}
+
+.title {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.subtitle {
+ font-size: 15px;
+ color: var(--text-secondary);
+ max-width: 560px;
+}
+
+.headerActions {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.stoSelect {
+ min-width: 220px;
+}
+
+.stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.statCard {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 150px;
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.statCardAttention {
+ border-color: rgba(255, 166, 87, 0.28);
+ background-color: rgba(255, 166, 87, 0.04);
+}
+
+.statLabel {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+}
+
+.statValue {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.board {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(300px, 1fr));
+ gap: 16px;
+ align-items: start;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+
+.columnHeader {
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.columnTitleRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.columnTitle {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.columnCount {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-secondary);
+ background-color: rgba(255, 255, 255, 0.06);
+}
+
+.columnDescription {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ line-height: 1.4;
+}
+
+.columnCards {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.emptyColumn {
+ padding: 24px 16px;
+ border-radius: var(--radius-md);
+ border: 1px dashed rgba(255, 255, 255, 0.1);
+ text-align: center;
+ font-size: 13px;
+ color: var(--text-tertiary);
+}
+
+.hint {
+ margin: 0 0 16px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+@media (max-width: 1100px) {
+ .board {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 700px) {
+ .header {
+ flex-direction: column;
+ }
+
+ .headerActions {
+ width: 100%;
+ }
+
+ .stoSelect {
+ width: 100%;
+ }
+}
diff --git a/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx
new file mode 100644
index 000000000..e2bee7594
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx
@@ -0,0 +1,185 @@
+import { useMemo, useState } from "react";
+import { BookingRequestCard } from "../../components/BookingRequestCard";
+import { BookingRequestDetailModal } from "../../components/BookingRequestDetailModal";
+import { Select, type SelectOption } from "../../components/Select";
+import { useBookingRequests } from "../../context/BookingRequestContext";
+import { useStos } from "../../context/StoContext";
+import type { BookingRequest, BookingRequestStatus } from "../../types/bookingRequest";
+import { BOOKING_STATUS_LABELS } from "../../utils/bookingLabels";
+import styles from "./BookingRequestsPage.module.css";
+
+const COLUMN_ORDER: BookingRequestStatus[] = ["pending", "confirmed", "rejected"];
+
+const COLUMN_DESCRIPTIONS: Record = {
+ pending: "Нові запити з мобільного додатку — потребують рішення менеджера",
+ confirmed: "Підтверджені записи, авто додані до черги «Записано»",
+ rejected: "Відхилені запити з поясненням для водія",
+};
+
+interface BookingColumnProps {
+ status: BookingRequestStatus;
+ requests: BookingRequest[];
+ onOpen: (request: BookingRequest) => void;
+}
+
+function BookingColumn({ status, requests, onOpen }: BookingColumnProps) {
+ const label = BOOKING_STATUS_LABELS[status];
+
+ return (
+
+
+
+ {label}
+ {requests.length}
+
+ {COLUMN_DESCRIPTIONS[status]}
+
+
+
+ {requests.length === 0 ? (
+ Немає запитів
+ ) : (
+ requests.map((request) => (
+
+ ))
+ )}
+
+
+ );
+}
+
+export function BookingRequestsPage() {
+ const { stos } = useStos();
+ const { getRequestsBySto, confirmRequest, rejectRequest } = useBookingRequests();
+
+ const activeStos = useMemo(
+ () => stos.filter((sto) => sto.status === "active"),
+ [stos],
+ );
+
+ const [selectedStoId, setSelectedStoId] = useState(
+ () => activeStos[0]?.id ?? stos[0]?.id ?? "",
+ );
+ const [selectedRequest, setSelectedRequest] = useState(null);
+
+ const stoOptions: SelectOption[] = useMemo(
+ () =>
+ (activeStos.length > 0 ? activeStos : stos).map((sto) => ({
+ value: sto.id,
+ label: sto.name,
+ })),
+ [activeStos, stos],
+ );
+
+ const allRequests = useMemo(
+ () => getRequestsBySto(selectedStoId),
+ [getRequestsBySto, selectedStoId],
+ );
+
+ const requestsByStatus = useMemo(() => {
+ const grouped = Object.fromEntries(
+ COLUMN_ORDER.map((status) => [status, [] as BookingRequest[]]),
+ ) as Record;
+
+ for (const request of allRequests) {
+ grouped[request.status].push(request);
+ }
+
+ for (const status of COLUMN_ORDER) {
+ grouped[status].sort(
+ (left, right) =>
+ new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
+ );
+ }
+
+ return grouped;
+ }, [allRequests]);
+
+ const detailRequest = useMemo(() => {
+ if (!selectedRequest) return null;
+ return allRequests.find((request) => request.id === selectedRequest.id) ?? selectedRequest;
+ }, [allRequests, selectedRequest]);
+
+ const selectedSto = stos.find((sto) => sto.id === selectedStoId);
+ const pendingCount = requestsByStatus.pending.length;
+
+ const handleConfirm = (requestId: string, appointmentAt: string, managerNote?: string) => {
+ const updated = confirmRequest(requestId, appointmentAt, managerNote);
+ if (updated) {
+ setSelectedRequest(updated);
+ }
+ };
+
+ const handleReject = (requestId: string, reason: string) => {
+ rejectRequest(requestId, reason);
+ setSelectedRequest((current) =>
+ current?.id === requestId
+ ? { ...current, status: "rejected", rejectionReason: reason }
+ : current,
+ );
+ };
+
+ return (
+
+
+
+
+ 0 ? styles.statCardAttention : ""}`}>
+ Очікують обробки
+ {pendingCount}
+
+
+ Підтверджено
+ {requestsByStatus.confirmed.length}
+
+
+ Відхилено
+ {requestsByStatus.rejected.length}
+
+
+ Всього
+ {allRequests.length}
+
+
+
+ Натисніть на картку запиту, щоб переглянути деталі та обробити
+
+
+ {COLUMN_ORDER.map((status) => (
+
+ ))}
+
+
+ setSelectedRequest(null)}
+ onConfirm={handleConfirm}
+ onReject={handleReject}
+ />
+
+ );
+}
diff --git a/apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts b/apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts
new file mode 100644
index 000000000..e3b428790
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts
@@ -0,0 +1 @@
+export { BookingRequestsPage } from "./BookingRequestsPage";
diff --git a/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css b/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css
index 5a5067058..73201a74d 100644
--- a/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css
+++ b/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css
@@ -31,6 +31,30 @@
padding: 16px;
}
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table th,
+.table td {
+ text-align: left;
+ padding: 10px 8px;
+ border-bottom: 1px solid var(--border-secondary);
+ font-size: 14px;
+}
+
+.table th {
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.empty {
+ text-align: center;
+ color: var(--text-secondary);
+ padding: 20px;
+}
+
.footer {
margin-top: 14px;
display: flex;
@@ -53,3 +77,20 @@
display: flex;
gap: 8px;
}
+
+.linkButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-sm);
+ padding: 6px 10px;
+ color: var(--text-primary);
+ background-color: var(--bg-tertiary);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.linkButton:hover {
+ background-color: var(--interactive-secondary-hover);
+}
diff --git a/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css
new file mode 100644
index 000000000..4d3d3de3f
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css
@@ -0,0 +1,124 @@
+.page {
+ width: 100%;
+ max-width: none;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 20px;
+}
+
+.headerText {
+ flex: 1;
+ min-width: 0;
+}
+
+.title {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.subtitle {
+ font-size: 15px;
+ color: var(--text-secondary);
+ max-width: 560px;
+}
+
+.headerActions {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.stoSelect {
+ min-width: 220px;
+}
+
+.stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.statCard {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 140px;
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.statLabel {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+}
+
+.statValue {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.tableCard {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-lg);
+ background-color: #1a1c1e;
+ padding: 4px 16px 16px;
+ overflow: hidden;
+}
+
+.plateCell {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-primary);
+ background-color: #1a4038;
+}
+
+.amountCell {
+ font-weight: 600;
+ color: var(--accent-primary);
+ white-space: nowrap;
+}
+
+.hint {
+ margin: 0 0 12px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+.empty {
+ padding: 32px 16px;
+ text-align: center;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+@media (max-width: 700px) {
+ .header {
+ flex-direction: column;
+ }
+
+ .headerActions {
+ width: 100%;
+ }
+
+ .stoSelect {
+ width: 100%;
+ }
+}
diff --git a/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx
new file mode 100644
index 000000000..941591431
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx
@@ -0,0 +1,162 @@
+import { useMemo, useState } from "react";
+import { DataTable, type DataTableColumn } from "../../components/DataTable";
+import { Select, type SelectOption } from "../../components/Select";
+import { VisitDetailModal } from "../../components/VisitDetailModal";
+import { VisitStatusBadge } from "../../components/VisitStatusBadge";
+import { useStos } from "../../context/StoContext";
+import { MOCK_VISITS } from "../../data/mockVisits";
+import type { VisitListItem, VisitStatus } from "../../types/visit";
+import {
+ calcVisitTotal,
+ formatVisitDate,
+ formatVisitPrice,
+ formatVisitTitle,
+ VISIT_STATUS_LABELS,
+} from "../../utils/visitLabels";
+import styles from "./VisitsPage.module.css";
+
+const VISIT_COLUMNS: DataTableColumn[] = [
+ {
+ key: "createdAt",
+ header: "Дата",
+ render: (visit) => formatVisitDate(visit.createdAt),
+ },
+ {
+ key: "vehicle",
+ header: "Автомобіль",
+ render: (visit) => (
+ <>
+ {formatVisitTitle(visit.brand, visit.model, visit.year)}
+ {visit.licensePlate}
+ >
+ ),
+ },
+ {
+ key: "client",
+ header: "Клієнт",
+ render: (visit) => visit.clientName,
+ },
+ {
+ key: "title",
+ header: "Візит",
+ render: (visit) => visit.title,
+ },
+ {
+ key: "sto",
+ header: "СТО",
+ render: (visit) => visit.stoName,
+ },
+ {
+ key: "mechanic",
+ header: "Механік",
+ render: (visit) => visit.mechanicName ?? "—",
+ },
+ {
+ key: "status",
+ header: "Статус",
+ render: (visit) => ,
+ },
+ {
+ key: "total",
+ header: "Сума",
+ render: (visit) => (
+ {formatVisitPrice(calcVisitTotal(visit.records))}
+ ),
+ },
+ ];
+
+function countByStatus(visits: VisitListItem[], status: VisitStatus): number {
+ return visits.filter((visit) => visit.status === status).length;
+}
+
+export function VisitsPage() {
+ const { stos } = useStos();
+ const [selectedStoId, setSelectedStoId] = useState("all");
+ const [selectedVisit, setSelectedVisit] = useState(null);
+
+ const activeStos = useMemo(
+ () => stos.filter((sto) => sto.status === "active"),
+ [stos],
+ );
+
+ const stoOptions: SelectOption[] = useMemo(
+ () => [
+ { value: "all", label: "Усі СТО" },
+ ...(activeStos.length > 0 ? activeStos : stos).map((sto) => ({
+ value: sto.id,
+ label: sto.name,
+ })),
+ ],
+ [activeStos, stos],
+ );
+
+ const visits = useMemo(() => {
+ const sorted = [...MOCK_VISITS].sort(
+ (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
+ );
+ if (selectedStoId === "all") return sorted;
+ return sorted.filter((visit) => visit.stoId === selectedStoId);
+ }, [selectedStoId]);
+
+ const columns = VISIT_COLUMNS;
+
+ return (
+
+
+
+
+
+ Всього
+ {visits.length}
+
+
+ {VISIT_STATUS_LABELS.in_progress}
+ {countByStatus(visits, "in_progress")}
+
+
+ {VISIT_STATUS_LABELS.waiting_payment}
+ {countByStatus(visits, "waiting_payment")}
+
+
+ {VISIT_STATUS_LABELS.completed}
+ {countByStatus(visits, "completed")}
+
+
+
+
+ Натисніть на рядок, щоб відкрити деталі візиту
+ visit.id}
+ emptyMessage="Візитів за обраним СТО не знайдено."
+ minWidth={980}
+ onRowClick={setSelectedVisit}
+ />
+
+
+ setSelectedVisit(null)}
+ />
+
+ );
+}
diff --git a/apps/AdminWebUi/src/pages/VisitsPage/index.ts b/apps/AdminWebUi/src/pages/VisitsPage/index.ts
new file mode 100644
index 000000000..ebab7d7e9
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/VisitsPage/index.ts
@@ -0,0 +1 @@
+export { VisitsPage } from "./VisitsPage";
diff --git a/apps/AdminWebUi/src/routes/AppRouter.tsx b/apps/AdminWebUi/src/routes/AppRouter.tsx
index e20166348..4f5ab3425 100644
--- a/apps/AdminWebUi/src/routes/AppRouter.tsx
+++ b/apps/AdminWebUi/src/routes/AppRouter.tsx
@@ -8,8 +8,10 @@ import { LoginPage } from "../pages/LoginPage";
import { PlaceholderPage } from "../pages/PlaceholderPage";
import { StoListPage } from "../pages/StoListPage";
import { StoQueuePage } from "../pages/StoQueuePage";
+import { BookingRequestsPage } from "../pages/BookingRequestsPage";
import { VehicleServiceHistoryPage } from "../pages/VehicleServiceHistoryPage";
import { VehiclesPage } from "../pages/VehiclesPage";
+import { VisitsPage } from "../pages/VisitsPage";
import { PermissionGate } from "./PermissionGate";
import { ProtectedRoute } from "./ProtectedRoute";
@@ -45,6 +47,24 @@ export function AppRouter() {
}
/>
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
= {
+ maintenance: "Регулярне ТО",
+ suspension: "Ходова та підвіска",
+ diagnostics: "Діагностика / Електрика",
+ other: "Інше / Складна поломка",
+};
+
+export const TIME_SLOT_LABELS: Record = {
+ morning: "Ранок",
+ day: "День",
+ evening: "Вечір",
+};
+
+export const TIME_SLOT_RANGES: Record = {
+ morning: "09:00 – 13:00",
+ day: "13:00 – 17:00",
+ evening: "17:00 – 19:00",
+};
+
+export const PARTS_OPTION_LABELS: Record = {
+ shop: "Запчастини від СТО",
+ own: "Свої запчастини",
+};
+
+export const BOOKING_STATUS_LABELS: Record = {
+ pending: "Очікує обробки",
+ confirmed: "Підтверджено",
+ rejected: "Відхилено",
+};
+
+export function formatPreferredSlot(date: string, slot: TimeSlotId): string {
+ const formattedDate = new Intl.DateTimeFormat("uk-UA", {
+ weekday: "short",
+ day: "2-digit",
+ month: "long",
+ }).format(new Date(`${date}T12:00:00`));
+
+ return `${formattedDate}, ${TIME_SLOT_LABELS[slot]} (${TIME_SLOT_RANGES[slot]})`;
+}
+
+export function buildProblemSummary(
+ visitReason: VisitReasonId,
+ problemDescription: string,
+): string {
+ return `${VISIT_REASON_LABELS[visitReason]}: ${problemDescription}`;
+}
+
+export function suggestedAppointmentIso(date: string, slot: TimeSlotId): string {
+ const hour = slot === "morning" ? 11 : slot === "day" ? 15 : 18;
+ const local = new Date(`${date}T${String(hour).padStart(2, "0")}:00:00`);
+ return local.toISOString();
+}
+
+export function toDatetimeLocalValue(isoDate: string): string {
+ const date = new Date(isoDate);
+ if (Number.isNaN(date.getTime())) return "";
+
+ const pad = (value: number) => String(value).padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+export function fromDatetimeLocalValue(value: string): string {
+ return new Date(value).toISOString();
+}
+
+export function formatBookingDateTime(isoDate: string): string {
+ return new Intl.DateTimeFormat("uk-UA", {
+ weekday: "short",
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(isoDate));
+}
diff --git a/apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts b/apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts
new file mode 100644
index 000000000..509dccf90
--- /dev/null
+++ b/apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts
@@ -0,0 +1,214 @@
+import type {
+ StoQueueVehicle,
+ StoVehicleActivityEntry,
+ StoVehicleApprovalRequest,
+} from "../types/stoVehicle";
+import { APPROVAL_TYPE_LABELS, formatPriceUah } from "./stoVehicleApprovals";
+import {
+ formatEventDateTime,
+ getVehicleStepIndex,
+ STO_VEHICLE_STATUS_LABELS,
+ STO_VEHICLE_STATUS_ORDER,
+} from "./stoVehicleLabels";
+
+function interpolateIsoTime(startIso: string, endIso: string, ratio: number): string {
+ const start = new Date(startIso).getTime();
+ const end = new Date(endIso).getTime();
+ if (Number.isNaN(start) || Number.isNaN(end) || end <= start) {
+ return endIso;
+ }
+ return new Date(start + (end - start) * ratio).toISOString();
+}
+
+function formatApprovalItemsSummary(request: StoVehicleApprovalRequest): string | undefined {
+ const items = request.lineItems ?? [];
+ if (items.length === 0) return undefined;
+
+ const preview = items
+ .slice(0, 3)
+ .map((item) => {
+ const qty = item.quantity && item.quantity > 1 ? ` ×${item.quantity}` : "";
+ return `${item.title}${qty}`;
+ })
+ .join(", ");
+
+ const suffix = items.length > 3 ? ` та ще ${items.length - 3}` : "";
+ const total = request.totalAmount;
+ const totalPart = total != null ? ` · ${formatPriceUah(total)}` : "";
+
+ return `${preview}${suffix}${totalPart}`;
+}
+
+function buildApprovalEntries(
+ vehicle: StoQueueVehicle,
+ request: StoVehicleApprovalRequest,
+): StoVehicleActivityEntry[] {
+ const entries: StoVehicleActivityEntry[] = [];
+ const typeLabel = APPROVAL_TYPE_LABELS[request.type];
+ const itemsSummary = formatApprovalItemsSummary(request);
+
+ entries.push({
+ id: `${request.id}-requested`,
+ type: "approval_requested",
+ occurredAt: request.createdAt,
+ actorName: request.requestedBy,
+ actorRole: "mechanic",
+ title: `Створено запит: ${typeLabel}`,
+ description: itemsSummary ?? request.description,
+ });
+
+ if (request.sentToClientAt) {
+ entries.push({
+ id: `${request.id}-sent`,
+ type: "approval_sent_to_client",
+ occurredAt: request.sentToClientAt,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: `Надіслано клієнту на погодження: ${typeLabel}`,
+ description: `Очікує відповіді від ${vehicle.clientName}`,
+ });
+ }
+
+ if (request.clientNotifiedAt) {
+ entries.push({
+ id: `${request.id}-notified`,
+ type: "approval_sent_to_client",
+ occurredAt: request.clientNotifiedAt,
+ actorName: "Система",
+ actorRole: "system",
+ title: `Нагадування клієнту: ${typeLabel}`,
+ description: `Повторне сповіщення для ${vehicle.clientName}`,
+ });
+ }
+
+ if (request.status === "approved" && request.reviewedAt) {
+ const byManager = request.approverRole === "manager";
+ entries.push({
+ id: `${request.id}-approved`,
+ type: "approval_approved",
+ occurredAt: request.reviewedAt,
+ actorName: request.approvedByName ?? (byManager ? "Менеджер СТО" : vehicle.clientName),
+ actorRole: byManager ? "manager" : "client",
+ title: `Погоджено: ${typeLabel}`,
+ description: request.managerOverrideReason ?? itemsSummary,
+ });
+ }
+
+ if (request.status === "rejected" && request.reviewedAt) {
+ entries.push({
+ id: `${request.id}-rejected`,
+ type: "approval_rejected",
+ occurredAt: request.reviewedAt,
+ actorName: request.approvedByName ?? vehicle.clientName,
+ actorRole: request.approverRole === "manager" ? "manager" : "client",
+ title: `Відхилено: ${typeLabel}`,
+ description: request.rejectionReason,
+ });
+ }
+
+ return entries;
+}
+
+function buildDerivedActivityLog(vehicle: StoQueueVehicle): StoVehicleActivityEntry[] {
+ const entries: StoVehicleActivityEntry[] = [];
+
+ entries.push({
+ id: `${vehicle.id}-created`,
+ type: "vehicle_created",
+ occurredAt: vehicle.createdAt,
+ actorName: vehicle.isManualEntry ? "Оператор СТО" : "Система",
+ actorRole: vehicle.isManualEntry ? "operator" : "system",
+ title: vehicle.isManualEntry ? "Автомобіль додано вручну" : "Автомобіль зареєстровано в черзі",
+ description: vehicle.problemSummary,
+ });
+
+ if (vehicle.appointmentAt) {
+ entries.push({
+ id: `${vehicle.id}-appointment`,
+ type: "appointment_scheduled",
+ occurredAt: vehicle.appointmentAt,
+ actorName: vehicle.clientName,
+ actorRole: "client",
+ title: "Запис на прийом",
+ description: formatEventDateTime(vehicle.appointmentAt),
+ });
+ }
+
+ if (vehicle.arrivedAt) {
+ entries.push({
+ id: `${vehicle.id}-arrived`,
+ type: "vehicle_arrived",
+ occurredAt: vehicle.arrivedAt,
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title: "Автомобіль прибув на СТО",
+ });
+ }
+
+ const currentIndex = getVehicleStepIndex(vehicle.status);
+ const statusesToLog = STO_VEHICLE_STATUS_ORDER.slice(0, currentIndex + 1);
+ const statusTimelineEnd = vehicle.updatedAt;
+
+ statusesToLog.forEach((status, index) => {
+ const ratio =
+ statusesToLog.length === 1 ? 0 : index / Math.max(statusesToLog.length - 1, 1);
+ const occurredAt = interpolateIsoTime(vehicle.createdAt, statusTimelineEnd, ratio);
+ const label = STO_VEHICLE_STATUS_LABELS[status];
+
+ entries.push({
+ id: `${vehicle.id}-status-${status}`,
+ type: "status_changed",
+ occurredAt,
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title:
+ index === 0
+ ? `Початковий етап: ${label}`
+ : `Етап змінено на «${label}»`,
+ });
+ });
+
+ if (vehicle.mechanicName) {
+ const mechanicTime = vehicle.arrivedAt
+ ? interpolateIsoTime(vehicle.arrivedAt, vehicle.updatedAt, 0.35)
+ : interpolateIsoTime(vehicle.createdAt, vehicle.updatedAt, 0.4);
+
+ entries.push({
+ id: `${vehicle.id}-mechanic`,
+ type: "mechanic_assigned",
+ occurredAt: mechanicTime,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Призначено механіка",
+ description: vehicle.mechanicName,
+ });
+ }
+
+ for (const request of vehicle.approvalRequests ?? []) {
+ entries.push(...buildApprovalEntries(vehicle, request));
+ }
+
+ return entries;
+}
+
+export function buildVehicleActivityLog(vehicle: StoQueueVehicle): StoVehicleActivityEntry[] {
+ const entries =
+ vehicle.activityLog && vehicle.activityLog.length > 0
+ ? [...vehicle.activityLog]
+ : buildDerivedActivityLog(vehicle);
+
+ return entries.sort(
+ (left, right) => new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
+ );
+}
+
+export const ACTIVITY_ACTOR_ROLE_LABELS: Record<
+ StoVehicleActivityEntry["actorRole"],
+ string
+> = {
+ system: "Система",
+ operator: "Оператор",
+ manager: "Менеджер",
+ mechanic: "Механік",
+ client: "Клієнт",
+};
diff --git a/apps/AdminWebUi/src/utils/visitActivityLog.ts b/apps/AdminWebUi/src/utils/visitActivityLog.ts
new file mode 100644
index 000000000..3dbeec842
--- /dev/null
+++ b/apps/AdminWebUi/src/utils/visitActivityLog.ts
@@ -0,0 +1,131 @@
+import type { StoVehicleActivityEntry } from "../types/stoVehicle";
+import type { VisitListItem, VisitStatus } from "../types/visit";
+import { formatVisitPrice, VISIT_STATUS_LABELS } from "./visitLabels";
+
+function interpolateIsoTime(startIso: string, endIso: string, ratio: number): string {
+ const start = new Date(startIso).getTime();
+ const end = new Date(endIso).getTime();
+ if (Number.isNaN(start) || Number.isNaN(end) || end <= start) {
+ return endIso;
+ }
+ return new Date(start + (end - start) * ratio).toISOString();
+}
+
+const VISIT_STATUS_ORDER: VisitStatus[] = [
+ "in_progress",
+ "waiting_payment",
+ "completed",
+];
+
+function getVisitStatusIndex(status: VisitStatus): number {
+ if (status === "cancelled") return -1;
+ return VISIT_STATUS_ORDER.indexOf(status);
+}
+
+function buildDerivedVisitActivityLog(visit: VisitListItem): StoVehicleActivityEntry[] {
+ const entries: StoVehicleActivityEntry[] = [];
+ const endTime = visit.completedAt ?? visit.updatedAt ?? visit.createdAt;
+
+ entries.push({
+ id: `${visit.id}-created`,
+ type: "vehicle_created",
+ occurredAt: visit.createdAt,
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title: "Візит відкрито",
+ description: visit.description || visit.title,
+ });
+
+ if (visit.mechanicName) {
+ entries.push({
+ id: `${visit.id}-mechanic`,
+ type: "mechanic_assigned",
+ occurredAt: interpolateIsoTime(visit.createdAt, endTime, 0.15),
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Призначено механіка",
+ description: visit.mechanicName,
+ });
+ }
+
+ const currentIndex = getVisitStatusIndex(visit.status);
+ if (currentIndex >= 0) {
+ const statusesToLog = VISIT_STATUS_ORDER.slice(0, currentIndex + 1);
+ statusesToLog.forEach((status, index) => {
+ const ratio =
+ statusesToLog.length === 1 ? 0.25 : 0.2 + index / Math.max(statusesToLog.length, 1) * 0.35;
+
+ entries.push({
+ id: `${visit.id}-status-${status}`,
+ type: "status_changed",
+ occurredAt: interpolateIsoTime(visit.createdAt, endTime, ratio),
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title: `Статус: ${VISIT_STATUS_LABELS[status]}`,
+ });
+ });
+ }
+
+ visit.records.forEach((record, index) => {
+ const ratio = 0.35 + (index / Math.max(visit.records.length, 1)) * 0.4;
+ entries.push({
+ id: `${visit.id}-record-${record.id}`,
+ type: "approval_requested",
+ occurredAt: interpolateIsoTime(visit.createdAt, endTime, ratio),
+ actorName: visit.mechanicName ?? "Механік",
+ actorRole: "mechanic",
+ title: `Додано до кошторису: ${record.title}`,
+ description: formatVisitPrice(record.price),
+ });
+ });
+
+ if (visit.status === "waiting_payment") {
+ entries.push({
+ id: `${visit.id}-payment`,
+ type: "approval_sent_to_client",
+ occurredAt: visit.updatedAt ?? endTime,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Надіслано клієнту на оплату",
+ description: `Очікує оплати від ${visit.clientName}`,
+ });
+ }
+
+ if (visit.status === "completed" && visit.completedAt) {
+ entries.push({
+ id: `${visit.id}-completed`,
+ type: "approval_approved",
+ occurredAt: visit.completedAt,
+ actorName: visit.clientName,
+ actorRole: "client",
+ title: "Візит завершено",
+ description: `Загальна сума: ${formatVisitPrice(
+ visit.records.reduce((sum, record) => sum + record.price, 0),
+ )}`,
+ });
+ }
+
+ if (visit.status === "cancelled") {
+ entries.push({
+ id: `${visit.id}-cancelled`,
+ type: "approval_rejected",
+ occurredAt: visit.updatedAt ?? endTime,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Візит скасовано",
+ });
+ }
+
+ return entries;
+}
+
+export function buildVisitActivityLog(visit: VisitListItem): StoVehicleActivityEntry[] {
+ const entries =
+ visit.activityLog && visit.activityLog.length > 0
+ ? [...visit.activityLog]
+ : buildDerivedVisitActivityLog(visit);
+
+ return entries.sort(
+ (left, right) => new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
+ );
+}
diff --git a/apps/AdminWebUi/src/utils/visitLabels.ts b/apps/AdminWebUi/src/utils/visitLabels.ts
new file mode 100644
index 000000000..0471b20be
--- /dev/null
+++ b/apps/AdminWebUi/src/utils/visitLabels.ts
@@ -0,0 +1,82 @@
+import type { VisitStatus } from "../types/visit";
+
+export const VISIT_STATUS_LABELS: Record = {
+ in_progress: "В роботі",
+ completed: "Завершено",
+ cancelled: "Скасовано",
+ waiting_payment: "Очікує оплати",
+};
+
+export interface VisitStatusBadgeStyle {
+ backgroundColor: string;
+ borderColor: string;
+ textColor: string;
+ dotColor: string;
+}
+
+export function getVisitStatusBadgeStyle(status: VisitStatus): VisitStatusBadgeStyle {
+ switch (status) {
+ case "in_progress":
+ return {
+ backgroundColor: "rgba(88, 166, 255, 0.15)",
+ borderColor: "rgba(88, 166, 255, 0.3)",
+ textColor: "#58a6ff",
+ dotColor: "#58a6ff",
+ };
+ case "completed":
+ return {
+ backgroundColor: "rgba(74, 222, 158, 0.15)",
+ borderColor: "rgba(74, 222, 158, 0.3)",
+ textColor: "#4ade9e",
+ dotColor: "#4ade9e",
+ };
+ case "waiting_payment":
+ return {
+ backgroundColor: "rgba(255, 166, 87, 0.14)",
+ borderColor: "rgba(255, 166, 87, 0.28)",
+ textColor: "#ffa657",
+ dotColor: "#ffa657",
+ };
+ case "cancelled":
+ return {
+ backgroundColor: "rgba(248, 81, 73, 0.12)",
+ borderColor: "rgba(248, 81, 73, 0.28)",
+ textColor: "#f85149",
+ dotColor: "#f85149",
+ };
+ }
+}
+
+export function formatVisitTitle(brand: string, model: string, year?: number): string {
+ return year ? `${brand} ${model} · ${year}` : `${brand} ${model}`;
+}
+
+export function formatVisitPrice(amount: number): string {
+ return new Intl.NumberFormat("uk-UA", {
+ style: "currency",
+ currency: "UAH",
+ maximumFractionDigits: 0,
+ }).format(amount);
+}
+
+export function calcVisitTotal(records: { price: number }[]): number {
+ return records.reduce((sum, record) => sum + record.price, 0);
+}
+
+export function formatVisitDate(isoDate: string): string {
+ return new Intl.DateTimeFormat("uk-UA", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ }).format(new Date(isoDate));
+}
+
+export function formatVisitDateTime(isoDate: string): string {
+ return new Intl.DateTimeFormat("uk-UA", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(isoDate));
+}
diff --git a/apps/Mobile/App.tsx b/apps/Mobile/App.tsx
index 7b74a8327..9964ce802 100644
--- a/apps/Mobile/App.tsx
+++ b/apps/Mobile/App.tsx
@@ -6,6 +6,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider } from "./src/context/AuthContext";
import { CarProvider } from "./src/context/CarContext";
import { NotificationsProvider } from "./src/context/NotificationsContext";
+import { AppAlertProvider } from "./src/components/AppAlert";
import AppNavigator from "./src/navigation/AppNavigator";
import { queryClient } from "./src/lib/queryClient";
import { configureStatusBar } from "./src/styles/globalStyles";
@@ -26,8 +27,10 @@ export default function App() {
-
-
+
+
+
+
diff --git a/apps/Mobile/src/api/authTokenBridge.ts b/apps/Mobile/src/api/authTokenBridge.ts
new file mode 100644
index 000000000..721fd44e7
--- /dev/null
+++ b/apps/Mobile/src/api/authTokenBridge.ts
@@ -0,0 +1,15 @@
+type RefreshAccessTokenFn = () => Promise;
+
+let refreshAccessTokenFn: RefreshAccessTokenFn | null = null;
+
+export function registerRefreshAccessToken(fn: RefreshAccessTokenFn): void {
+ refreshAccessTokenFn = fn;
+}
+
+export async function tryRefreshAccessToken(): Promise {
+ if (!refreshAccessTokenFn) {
+ return null;
+ }
+
+ return refreshAccessTokenFn();
+}
diff --git a/apps/Mobile/src/api/client.ts b/apps/Mobile/src/api/client.ts
index ff6e92b27..74bd9b06b 100644
--- a/apps/Mobile/src/api/client.ts
+++ b/apps/Mobile/src/api/client.ts
@@ -1,7 +1,10 @@
import { API_CONFIG, getNgrokHeaders } from "../config/api";
+import { tryRefreshAccessToken } from "./authTokenBridge";
const API_BASE_URL = API_CONFIG.BASE_URL;
+type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
+
export class ApiClient {
private baseURL: string;
private getAccessToken: () => Promise;
@@ -14,149 +17,89 @@ export class ApiClient {
this.getAccessToken = getAccessToken;
}
- private async getHeaders(): Promise {
- const token = await this.getAccessToken();
+ private async getHeaders(token?: string | null): Promise {
+ const accessToken = token ?? (await this.getAccessToken());
return {
"Content-Type": "application/json",
...getNgrokHeaders(),
- ...(token && { Authorization: `Bearer ${token}` }),
+ ...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
}
- async get(endpoint: string): Promise {
- const response = await fetch(`${this.baseURL}${endpoint}`, {
- method: "GET",
- headers: await this.getHeaders(),
- });
-
- if (!response.ok) {
- // Спробуємо отримати текст помилки
- let errorMessage = `HTTP error! status: ${response.status}`;
- try {
- const errorText = await response.text();
- if (errorText) {
- errorMessage = errorText;
- }
- } catch (e) {
- // Ігноруємо помилки парсингу тексту
+ private async readErrorMessage(response: Response): Promise {
+ let errorMessage = `HTTP error! status: ${response.status}`;
+ try {
+ const errorText = await response.text();
+ if (errorText) {
+ errorMessage = errorText;
}
- throw new Error(errorMessage);
- }
-
- // Перевіряємо чи відповідь є JSON
- const contentType = response.headers.get("content-type");
- if (!contentType || !contentType.includes("application/json")) {
- const responseText = await response.text();
- throw new Error(
- `Expected JSON response but got: ${contentType}. Response: ${responseText.substring(0, 200)}...`
- );
+ } catch {
+ // Ignore body read errors.
}
-
- return response.json();
+ return errorMessage;
}
- async post(endpoint: string, data: any): Promise {
+ private async request(
+ method: HttpMethod,
+ endpoint: string,
+ body?: unknown,
+ hasRetried = false
+ ): Promise {
const response = await fetch(`${this.baseURL}${endpoint}`, {
- method: "POST",
+ method,
headers: await this.getHeaders(),
- body: JSON.stringify(data),
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
- if (!response.ok) {
- // Спробуємо отримати текст помилки
- let errorMessage = `HTTP error! status: ${response.status}`;
- try {
- const errorText = await response.text();
- if (errorText) {
- errorMessage = errorText;
- }
- } catch (e) {
- // Ігноруємо помилки парсингу тексту
+ if (response.status === 401 && !hasRetried) {
+ const refreshedToken = await tryRefreshAccessToken();
+ if (refreshedToken) {
+ return this.request(method, endpoint, body, true);
}
- throw new Error(errorMessage);
}
- // Перевіряємо чи є контент для парсингу
- const contentType = response.headers.get("content-type");
- const contentLength = response.headers.get("content-length");
-
- // Якщо немає контенту або content-length = 0, повертаємо порожній об'єкт
- if (!contentLength || contentLength === "0") {
- return {} as T;
- }
-
- // Перевіряємо чи відповідь є JSON
- if (!contentType || !contentType.includes("application/json")) {
- const responseText = await response.text();
- throw new Error(
- `Expected JSON response but got: ${contentType}. Response: ${responseText.substring(0, 200)}...`
- );
+ if (!response.ok) {
+ throw new Error(await this.readErrorMessage(response));
}
- return response.json();
- }
-
- async put(endpoint: string, data: any): Promise {
- const response = await fetch(`${this.baseURL}${endpoint}`, {
- method: "PUT",
- headers: await this.getHeaders(),
- body: JSON.stringify(data),
- });
-
- if (!response.ok) {
- // Спробуємо отримати текст помилки
- let errorMessage = `HTTP error! status: ${response.status}`;
- try {
- const errorText = await response.text();
- if (errorText) {
- errorMessage = errorText;
- }
- } catch (e) {
- // Ігноруємо помилки парсингу тексту
- }
- throw new Error(errorMessage);
+ if (response.status === 204) {
+ return {} as T;
}
- // Перевіряємо чи є контент для парсингу
const contentType = response.headers.get("content-type");
- const contentLength = response.headers.get("content-length");
+ const responseText = await response.text();
- // Якщо немає контенту або content-length = 0, повертаємо порожній об'єкт
- if (!contentLength || contentLength === "0") {
+ if (!responseText) {
return {} as T;
}
- // Перевіряємо чи відповідь є JSON
if (!contentType || !contentType.includes("application/json")) {
- const responseText = await response.text();
throw new Error(
`Expected JSON response but got: ${contentType}. Response: ${responseText.substring(0, 200)}...`
);
}
- return response.json();
+ return JSON.parse(responseText) as T;
}
- async delete(endpoint: string): Promise {
- const response = await fetch(`${this.baseURL}${endpoint}`, {
- method: "DELETE",
- headers: await this.getHeaders(),
- });
+ async get(endpoint: string): Promise {
+ return this.request("GET", endpoint);
+ }
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
+ async post(endpoint: string, data: unknown): Promise {
+ return this.request("POST", endpoint, data);
+ }
- // 204 No Content — тіла немає, не викликаємо .json()
- if (response.status === 204) {
- return {} as T;
- }
- return response.json();
+ async put(endpoint: string, data: unknown): Promise {
+ return this.request("PUT", endpoint, data);
+ }
+
+ async delete(endpoint: string): Promise {
+ return this.request("DELETE", endpoint);
}
}
-// Функція для створення API клієнта з токеном
export const createApiClient = (
getAccessToken: () => Promise
) => {
diff --git a/apps/Mobile/src/components/AppAlert/AppAlert.tsx b/apps/Mobile/src/components/AppAlert/AppAlert.tsx
new file mode 100644
index 000000000..69a033f1e
--- /dev/null
+++ b/apps/Mobile/src/components/AppAlert/AppAlert.tsx
@@ -0,0 +1,178 @@
+import React from "react";
+import {
+ Modal,
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+} from "react-native";
+import { globalStyles } from "../../styles/globalStyles";
+
+export type AppAlertButtonStyle = "default" | "cancel" | "destructive";
+
+export interface AppAlertButton {
+ text: string;
+ onPress?: () => void;
+ style?: AppAlertButtonStyle;
+}
+
+export interface AppAlertProps {
+ visible: boolean;
+ title: string;
+ message?: string;
+ buttons?: AppAlertButton[];
+ onDismiss: () => void;
+}
+
+export function AppAlert({
+ visible,
+ title,
+ message,
+ buttons = [{ text: "OK", style: "default" }],
+ onDismiss,
+}: AppAlertProps) {
+ const handlePress = (button: AppAlertButton) => {
+ onDismiss();
+ button.onPress?.();
+ };
+
+ return (
+
+
+
+
+ {title}
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ 1 && styles.actionsMultiple,
+ ]}
+ >
+ {buttons.map((button, index) => {
+ const isDestructive = button.style === "destructive";
+ const isCancel = button.style === "cancel";
+ const isPrimary = !isCancel && !isDestructive;
+
+ return (
+ 1 && styles.actionButtonMultiple,
+ isPrimary && styles.actionButtonPrimary,
+ isCancel && styles.actionButtonSecondary,
+ isDestructive && styles.actionButtonDestructive,
+ ]}
+ onPress={() => handlePress(button)}
+ activeOpacity={0.85}
+ >
+
+ {button.text}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 24,
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
+ },
+ card: {
+ width: "100%",
+ maxWidth: 360,
+ backgroundColor: "#161B22",
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: "#30363D",
+ paddingHorizontal: 20,
+ paddingTop: 20,
+ paddingBottom: 16,
+ },
+ title: {
+ fontSize: 18,
+ marginBottom: 8,
+ textAlign: "center",
+ },
+ message: {
+ textAlign: "center",
+ lineHeight: 22,
+ marginBottom: 20,
+ },
+ actions: {
+ gap: 10,
+ },
+ actionsMultiple: {
+ flexDirection: "column-reverse",
+ },
+ actionButton: {
+ borderRadius: 12,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ actionButtonMultiple: {
+ width: "100%",
+ },
+ actionButtonPrimary: {
+ backgroundColor: "#4ade9e",
+ },
+ actionButtonSecondary: {
+ backgroundColor: "#21262D",
+ borderWidth: 1,
+ borderColor: "#30363D",
+ },
+ actionButtonDestructive: {
+ backgroundColor: "transparent",
+ borderWidth: 1,
+ borderColor: "#F85149",
+ },
+ actionButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ actionButtonTextPrimary: {
+ color: "#121212",
+ },
+ actionButtonTextSecondary: {
+ color: "#FFFFFF",
+ },
+ actionButtonTextDestructive: {
+ color: "#F85149",
+ },
+});
diff --git a/apps/Mobile/src/components/AppAlert/AppAlertContext.tsx b/apps/Mobile/src/components/AppAlert/AppAlertContext.tsx
new file mode 100644
index 000000000..3f5101694
--- /dev/null
+++ b/apps/Mobile/src/components/AppAlert/AppAlertContext.tsx
@@ -0,0 +1,94 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+} from "react";
+import { AppAlert, AppAlertButton } from "./AppAlert";
+
+export interface ShowAppAlertOptions {
+ title: string;
+ message?: string;
+ buttons?: AppAlertButton[];
+}
+
+interface AppAlertState extends ShowAppAlertOptions {
+ visible: boolean;
+}
+
+interface AppAlertContextValue {
+ showAlert: (options: ShowAppAlertOptions) => void;
+ showError: (message: string, title?: string) => void;
+ showSuccess: (
+ message: string,
+ title?: string,
+ buttons?: AppAlertButton[]
+ ) => void;
+ hideAlert: () => void;
+}
+
+const AppAlertContext = createContext(null);
+
+export function AppAlertProvider({ children }: { children: React.ReactNode }) {
+ const [alertState, setAlertState] = useState(null);
+
+ const hideAlert = useCallback(() => {
+ setAlertState(null);
+ }, []);
+
+ const showAlert = useCallback((options: ShowAppAlertOptions) => {
+ setAlertState({
+ ...options,
+ visible: true,
+ buttons: options.buttons ?? [{ text: "OK", style: "default" }],
+ });
+ }, []);
+
+ const showError = useCallback(
+ (message: string, title = "Помилка") => {
+ showAlert({ title, message });
+ },
+ [showAlert]
+ );
+
+ const showSuccess = useCallback(
+ (message: string, title = "Успішно!", buttons?: AppAlertButton[]) => {
+ showAlert({
+ title,
+ message,
+ buttons: buttons ?? [{ text: "OK", style: "default" }],
+ });
+ },
+ [showAlert]
+ );
+
+ const value: AppAlertContextValue = {
+ showAlert,
+ showError,
+ showSuccess,
+ hideAlert,
+ };
+
+ return (
+
+ {children}
+ {alertState ? (
+
+ ) : null}
+
+ );
+}
+
+export function useAppAlert() {
+ const context = useContext(AppAlertContext);
+ if (!context) {
+ throw new Error("useAppAlert must be used within an AppAlertProvider");
+ }
+ return context;
+}
diff --git a/apps/Mobile/src/components/AppAlert/index.ts b/apps/Mobile/src/components/AppAlert/index.ts
new file mode 100644
index 000000000..e74dbad36
--- /dev/null
+++ b/apps/Mobile/src/components/AppAlert/index.ts
@@ -0,0 +1,4 @@
+export { AppAlert } from "./AppAlert";
+export type { AppAlertButton, AppAlertButtonStyle, AppAlertProps } from "./AppAlert";
+export { AppAlertProvider, useAppAlert } from "./AppAlertContext";
+export type { ShowAppAlertOptions } from "./AppAlertContext";
diff --git a/apps/Mobile/src/components/AppAlert/useAppAlert.tsx b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx
new file mode 100644
index 000000000..f74c9fa18
--- /dev/null
+++ b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx
@@ -0,0 +1,2 @@
+export { AppAlertProvider, useAppAlert } from "./AppAlertContext";
+export type { ShowAppAlertOptions } from "./AppAlertContext";
diff --git a/apps/Mobile/src/components/Select/Select.styles.ts b/apps/Mobile/src/components/Select/Select.styles.ts
index c88bcac21..98c9c04b2 100644
--- a/apps/Mobile/src/components/Select/Select.styles.ts
+++ b/apps/Mobile/src/components/Select/Select.styles.ts
@@ -72,9 +72,12 @@ export const styles = StyleSheet.create({
borderWidth: 1,
borderBottomWidth: 0,
borderColor: "rgba(255, 255, 255, 0.08)",
- maxHeight: "85%",
+ height: "85%",
overflow: "hidden",
},
+ optionsBody: {
+ flex: 1,
+ },
handle: {
alignSelf: "center",
width: 40,
@@ -83,23 +86,27 @@ export const styles = StyleSheet.create({
backgroundColor: "rgba(255, 255, 255, 0.15)",
marginTop: 10,
marginBottom: 4,
+ flexShrink: 0,
},
sheetHeader: {
flexDirection: "row",
- alignItems: "center",
+ alignItems: "flex-start",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 12,
+ flexShrink: 0,
},
sheetHeaderContent: {
flex: 1,
+ minWidth: 0,
+ marginRight: 12,
},
sheetTitle: {
color: "#FFFFFF",
fontSize: 18,
fontWeight: "700",
- flex: 1,
+ lineHeight: 24,
},
sheetSubtitle: {
color: "#8E8E93",
@@ -113,10 +120,12 @@ export const styles = StyleSheet.create({
backgroundColor: "rgba(255, 255, 255, 0.06)",
alignItems: "center",
justifyContent: "center",
+ marginTop: 2,
},
searchContainer: {
paddingHorizontal: 16,
paddingBottom: 12,
+ flexShrink: 0,
},
searchInputWrapper: {
flexDirection: "row",
@@ -135,7 +144,7 @@ export const styles = StyleSheet.create({
paddingVertical: 11,
},
optionsList: {
- flexGrow: 0,
+ flex: 1,
},
optionsListContent: {
paddingHorizontal: 12,
@@ -167,9 +176,11 @@ export const styles = StyleSheet.create({
fontWeight: "600",
},
noResultsContainer: {
+ flex: 1,
paddingVertical: 36,
paddingHorizontal: 24,
alignItems: "center",
+ justifyContent: "center",
gap: 8,
},
noResultsTitle: {
@@ -187,6 +198,7 @@ export const styles = StyleSheet.create({
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: "rgba(255, 255, 255, 0.06)",
+ flexShrink: 0,
},
doneButton: {
alignItems: "center",
diff --git a/apps/Mobile/src/components/Select/Select.tsx b/apps/Mobile/src/components/Select/Select.tsx
index af48ca048..7d3324ef9 100644
--- a/apps/Mobile/src/components/Select/Select.tsx
+++ b/apps/Mobile/src/components/Select/Select.tsx
@@ -6,8 +6,6 @@ import {
TouchableOpacity,
Modal,
FlatList,
- Platform,
- Dimensions,
} from "react-native";
import { Feather } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -70,7 +68,6 @@ export const Select: React.FC = ({
const selectedOption = options.find((option) => option.value === value);
const showSearch = options.length > searchThreshold;
const modalTitle = label || "Виберіть опцію";
- const listMaxHeight = Dimensions.get("window").height * 0.42;
const filteredOptions = useMemo(() => {
if (!searchQuery.trim()) {
@@ -211,7 +208,6 @@ export const Select: React.FC = ({
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
- autoFocus={Platform.OS === "ios"}
/>
{searchQuery.length > 0 ? (
= ({
) : null}
- {filteredOptions.length > 0 ? (
- String(item.value)}
- style={[styles.optionsList, { maxHeight: listMaxHeight }]}
- contentContainerStyle={styles.optionsListContent}
- renderItem={renderOption}
- keyboardShouldPersistTaps="handled"
- showsVerticalScrollIndicator={false}
- />
- ) : (
-
-
- Нічого не знайдено
-
- Спробуйте інший пошуковий запит
-
-
- )}
+
+ {filteredOptions.length > 0 ? (
+ String(item.value)}
+ style={styles.optionsList}
+ contentContainerStyle={styles.optionsListContent}
+ renderItem={renderOption}
+ keyboardShouldPersistTaps="handled"
+ showsVerticalScrollIndicator={false}
+ />
+ ) : (
+
+
+ Нічого не знайдено
+
+ Спробуйте інший пошуковий запит
+
+
+ )}
+
= ({
containerStyle,
}) => {
const theme = useTheme();
+ const { showSuccess, showError } = useAppAlert();
const [vinDecode, setVinDecode] = useState("");
const [isDecodingVin, setIsDecodingVin] = useState(false);
@@ -54,14 +56,14 @@ export const VinDecoder: React.FC = ({
driveType: vinData.DriveType,
});
- Alert.alert("Успішно!", "Дані про автомобіль отримано та заповнено");
+ showSuccess("Дані про автомобіль отримано та заповнено", "Успішно!");
} catch (error) {
console.error("Error decoding VIN:", error);
const errorMessage =
error instanceof Error
? error.message
: "Не вдалося розшифрувати VIN номер. Перевірте правильність введення.";
- Alert.alert("Помилка", errorMessage);
+ showError(errorMessage);
} finally {
setIsDecodingVin(false);
}
@@ -75,7 +77,7 @@ export const VinDecoder: React.FC = ({
}, 500); // 500ms затримка після останнього введення
return () => clearTimeout(timeoutId);
- }, [vinDecode, onDecodeSuccess]);
+ }, [vinDecode, onDecodeSuccess, showSuccess, showError]);
return (
diff --git a/apps/Mobile/src/context/AuthContext.tsx b/apps/Mobile/src/context/AuthContext.tsx
index 032a14019..0ab5d8d5b 100644
--- a/apps/Mobile/src/context/AuthContext.tsx
+++ b/apps/Mobile/src/context/AuthContext.tsx
@@ -15,6 +15,7 @@ import {
refreshAccessToken,
} from "../api/services/authService";
import { queryClient } from "../lib/queryClient";
+import { registerRefreshAccessToken } from "../api/authTokenBridge";
interface AuthContextType {
isAuthenticated: boolean;
@@ -137,6 +138,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
checkAuthStatus();
}, [checkAuthStatus]);
+ useEffect(() => {
+ registerRefreshAccessToken(refreshSession);
+ }, [refreshSession]);
+
const login = async () => {
const result = await WebBrowser.openAuthSessionAsync(
`${API_CONFIG.BASE_URL}/auth/login`,
diff --git a/apps/Mobile/src/queries/vehicleQueries.ts b/apps/Mobile/src/queries/vehicleQueries.ts
index 901fea998..aa5389d7d 100644
--- a/apps/Mobile/src/queries/vehicleQueries.ts
+++ b/apps/Mobile/src/queries/vehicleQueries.ts
@@ -11,9 +11,10 @@ export const useVehicles = () => {
return useQuery({
queryKey: queryKeys.vehicles,
- queryFn: () => {
+ queryFn: async () => {
const apiClient = createApiClient(getAccessToken);
- return apiClient.get(ENDPOINTS.VEHICLES);
+ const result = await apiClient.get(ENDPOINTS.VEHICLES);
+ return Array.isArray(result) ? result : [];
},
staleTime: CACHE_STALE_TIME_MS,
});
@@ -66,6 +67,7 @@ export const useDeleteVehicle = () => {
},
onSuccess: (_, vehicleId) => {
queryClient.removeQueries({ queryKey: queryKeys.vehicle(vehicleId) });
+ queryClient.removeQueries({ queryKey: queryKeys.serviceHistory(vehicleId) });
queryClient.invalidateQueries({ queryKey: queryKeys.vehicles });
},
});
diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts
index d17f853e8..bd4c3b959 100644
--- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts
+++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts
@@ -1,90 +1,219 @@
import { StyleSheet } from "react-native";
+import { theme } from "../../styles/theme";
export const styles = StyleSheet.create({
- scrollContainer: {
- flexGrow: 1,
- paddingHorizontal: 24,
+ contentContainer: {
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 24,
},
- header: {
- alignItems: "center",
- marginTop: 20,
- marginBottom: 40,
+ pageTitle: {
+ color: "#FFFFFF",
+ fontSize: 24,
+ fontWeight: "700",
+ marginBottom: 4,
},
- title: {
- marginBottom: 8,
- textAlign: "center",
- },
- subtitle: {
- textAlign: "center",
+ pageSubtitle: {
+ color: "#8E8E93",
+ fontSize: 14,
+ marginBottom: 18,
+ lineHeight: 20,
+ },
+ stepHeader: {
+ marginBottom: 14,
+ gap: 6,
+ },
+ stepCounter: {
+ color: theme.colors.accent.primary,
+ fontSize: 12,
+ fontWeight: "600",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ stepTitle: {
+ color: "#FFFFFF",
+ fontSize: 17,
+ fontWeight: "600",
+ },
+ stepProgress: {
+ flexDirection: "row",
+ gap: 6,
+ marginTop: 4,
},
- form: {
+ stepDot: {
flex: 1,
+ height: 4,
+ borderRadius: 999,
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
},
- imageSection: {
- alignItems: "center",
- marginBottom: 32,
- },
- imageContainer: {
- width: 200,
- height: 120,
- borderRadius: 12,
- borderWidth: 2,
- borderColor: "#30363D", // Using theme color directly
- borderStyle: "dashed",
- justifyContent: "center",
- alignItems: "center",
+ stepDotCompleted: {
+ backgroundColor: theme.colors.accent.primary,
+ },
+ stepDotActive: {
+ backgroundColor: "#FFFFFF",
+ },
+ section: {
+ backgroundColor: "#1A1C1E",
+ borderRadius: 16,
+ padding: 16,
+ marginBottom: 14,
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.05)",
+ gap: 14,
+ },
+ field: {
+ gap: 6,
},
- carImage: {
- width: 196,
- height: 116,
+ label: {
+ color: "#8E8E93",
+ fontSize: 13,
+ },
+ input: {
+ backgroundColor: "#232527",
borderRadius: 10,
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.08)",
+ color: "#FFFFFF",
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ fontSize: 15,
+ },
+ dateInput: {
+ justifyContent: "center",
},
- placeholderImage: {
- alignItems: "center",
+ dateInputText: {
+ color: "#FFFFFF",
+ fontSize: 15,
},
- placeholderText: {
- fontSize: 32,
- marginBottom: 8,
+ selectContainer: {
+ marginBottom: 0,
},
- placeholderLabel: {
- fontSize: 14,
- color: "#8B949E", // Using theme color directly
+ loadingIndicator: {
+ marginTop: 4,
+ alignItems: "flex-start",
},
- inputContainer: {
- marginBottom: 20,
+ photoCard: {
+ borderRadius: 14,
+ overflow: "hidden",
+ backgroundColor: "#121212",
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.06)",
+ },
+ photoImage: {
+ width: "100%",
+ height: 180,
+ backgroundColor: "#121212",
+ },
+ photoPlaceholder: {
+ width: "100%",
+ height: 180,
+ backgroundColor: theme.colors.accent.dark,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
},
- label: {
- marginBottom: 8,
+ photoPlaceholderText: {
+ color: "#FFFFFF",
+ fontSize: 14,
+ fontWeight: "500",
},
- input: {
- // Additional input styles if needed
+ photoOverlay: {
+ position: "absolute",
+ bottom: 0,
+ left: 0,
+ right: 0,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingVertical: 12,
+ backgroundColor: "rgba(0, 0, 0, 0.55)",
+ },
+ photoOverlayText: {
+ color: "#FFFFFF",
+ fontSize: 14,
+ fontWeight: "600",
},
- vinDecodeContainer: {
+ actionsRow: {
flexDirection: "row",
- gap: 12,
- alignItems: "flex-start",
+ gap: 10,
+ marginTop: 4,
},
- vinDecodeInput: {
+ halfButton: {
flex: 1,
},
- decodeButton: {
- minWidth: 120,
- justifyContent: "center",
+ primaryButton: {
+ flexDirection: "row",
alignItems: "center",
- paddingHorizontal: 16,
+ justifyContent: "center",
+ gap: 8,
+ backgroundColor: theme.colors.interactive.primary,
+ borderRadius: 14,
+ paddingVertical: 14,
},
- decodeButtonDisabled: {
- opacity: 0.6,
+ primaryButtonText: {
+ color: theme.colors.text.inverse,
+ fontSize: 15,
+ fontWeight: "600",
},
- buttonContainer: {
+ secondaryButton: {
flexDirection: "row",
- justifyContent: "space-between",
- marginTop: 20,
- marginBottom: 40,
- gap: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ backgroundColor: theme.colors.accent.dark,
+ borderRadius: 14,
+ paddingVertical: 14,
+ borderWidth: 1,
+ borderColor: "rgba(74, 222, 158, 0.15)",
+ },
+ secondaryButtonText: {
+ color: theme.colors.accent.primary,
+ fontSize: 15,
+ fontWeight: "600",
+ },
+ datePickerModalRoot: {
+ flex: 1,
+ justifyContent: "flex-end",
+ },
+ datePickerBackdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0, 0, 0, 0.55)",
+ },
+ datePickerSheet: {
+ backgroundColor: "#1A1C1E",
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ borderWidth: 1,
+ borderBottomWidth: 0,
+ borderColor: "rgba(255, 255, 255, 0.08)",
+ paddingBottom: 24,
+ },
+ datePickerHandle: {
+ alignSelf: "center",
+ width: 40,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ marginTop: 10,
+ marginBottom: 8,
},
- loadingIndicator: {
- marginTop: 8,
+ datePickerHeader: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ paddingBottom: 8,
+ },
+ datePickerDoneText: {
+ color: theme.colors.accent.primary,
+ fontSize: 15,
+ fontWeight: "600",
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
alignItems: "center",
+ paddingHorizontal: 24,
},
});
diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx
index b90b320eb..e378468db 100644
--- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx
+++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx
@@ -1,23 +1,29 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useRef } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
Image,
- Alert,
Platform,
ActivityIndicator,
+ Animated,
+ Easing,
+ Modal,
} from "react-native";
+import ReanimatedAnimated, { FadeIn } from "react-native-reanimated";
+import { Feather } from "@expo/vector-icons";
import { FormScreen } from "../../components/FormScreen";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as ImagePicker from "expo-image-picker";
import { useAuth } from "../../context/AuthContext";
import { useTheme } from "../../hooks/useTheme";
+import { useStatusBar } from "../../hooks/useStatusBar";
import { globalStyles } from "../../styles/globalStyles";
import { styles } from "./CarCardScreen.styles";
import { CarCardScreenProps } from "../../navigation/types";
import { Select, SelectOption } from "../../components/Select";
+import { useAppAlert } from "../../components/AppAlert";
import {
getAllMakes,
getModelsForMakeId,
@@ -33,6 +39,21 @@ import {
TransmissionType,
WheelDriveType,
} from "../../types/api";
+import {
+ isValidVin,
+ normalizeVinInput,
+ VIN_VALIDATION_MESSAGE,
+} from "../../utils/vinUtils";
+import { parseApiErrorMessage } from "../../utils/apiErrorUtils";
+import {
+ getEnginePowerLabel,
+ getTransmissionOptionsForFuel,
+ isTransmissionAllowedForFuel,
+ normalizeFuelType,
+ normalizeTransmissionType,
+ requiresEngineDisplacement,
+ resolveEngineCapacityCc,
+} from "../../utils/vehicleFormUtils";
const COLOR_OPTIONS: SelectOption[] = [
{ label: "Білий", value: "white" },
@@ -50,15 +71,15 @@ const COLOR_OPTIONS: SelectOption[] = [
];
const BODY_CLASS_OPTIONS: SelectOption[] = [
- { label: "Sedan", value: "Sedan" },
- { label: "Hatchback", value: "Hatchback" },
- { label: "Universal", value: "Universal" },
- { label: "Coupe", value: "Coupe" },
- { label: "Convertible", value: "Convertible" },
- { label: "SUV", value: "SUV" },
- { label: "Crossover", value: "Crossover" },
- { label: "Minivan", value: "Minivan" },
- { label: "Pickup", value: "Pickup" },
+ { label: "Седан", value: "Sedan" },
+ { label: "Хетчбек", value: "Hatchback" },
+ { label: "Універсал", value: "Universal" },
+ { label: "Купе", value: "Coupe" },
+ { label: "Кабріолет", value: "Convertible" },
+ { label: "Позашляховик", value: "SUV" },
+ { label: "Кросовер", value: "Crossover" },
+ { label: "Мінівен", value: "Minivan" },
+ { label: "Пікап", value: "Pickup" },
];
// Відповідають enum FuelType на бекенді (0–5)
@@ -86,10 +107,22 @@ const WHEEL_DRIVE_API_OPTIONS: SelectOption[] = [
{ label: "4WD", value: WheelDriveType.FourWD },
];
+const TOTAL_STEPS = 6;
+const STEP_TITLES = [
+ "Базова інформація",
+ "Ідентифікація авто",
+ "Історія використання",
+ "Двигун",
+ "Трансмісія та привід",
+ "Фото та завершення",
+];
+
export default function CarCardScreen({ navigation }: CarCardScreenProps) {
+ useStatusBar();
const { getAccessToken } = useAuth();
const queryClient = useQueryClient();
const theme = useTheme();
+ const { showAlert, showError, showSuccess } = useAppAlert();
const [carImage, setCarImage] = useState(null);
const [brand, setBrand] = useState(undefined);
const [model, setModel] = useState(undefined);
@@ -97,12 +130,17 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
const [color, setColor] = useState(undefined);
const [licensePlate, setLicensePlate] = useState("");
const [vin, setVin] = useState("");
- const [bodyClass, setBodyClass] = useState(undefined);
+ const [bodyClass, setBodyClass] = useState(
+ undefined
+ );
const [fuelType, setFuelType] = useState(undefined);
const [displacement, setDisplacement] = useState("");
- const [transmission, setTransmission] = useState(undefined);
+ const [transmission, setTransmission] = useState(
+ undefined
+ );
const [driveType, setDriveType] = useState(undefined);
- const [boughtAt, setBoughtAt] = useState(null);
+ const [boughtAt, setBoughtAt] = useState(() => new Date());
+ const [pickerDate, setPickerDate] = useState(() => new Date());
const [showBoughtAtPicker, setShowBoughtAtPicker] = useState(false);
const [mileage, setMileage] = useState("");
const [enginePower, setEnginePower] = useState("");
@@ -114,6 +152,8 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
const [yearOptions] = useState(generateYearOptions());
const [isLoadingBrands, setIsLoadingBrands] = useState(false);
const [isLoadingModels, setIsLoadingModels] = useState(false);
+ const [currentStep, setCurrentStep] = useState(0);
+ const stepAnimation = useRef(new Animated.Value(1)).current;
// Завантаження марок при монтуванні компонента
useEffect(() => {
@@ -128,17 +168,14 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
setBrandOptions(options);
} catch (error) {
console.error("Error loading brands:", error);
- Alert.alert(
- "Помилка",
- "Не вдалося завантажити список марок. Спробуйте пізніше."
- );
+ showError("Не вдалося завантажити список марок. Спробуйте пізніше.");
} finally {
setIsLoadingBrands(false);
}
};
loadBrands();
- }, []);
+ }, [showError]);
// Завантаження моделей при зміні марки
useEffect(() => {
@@ -146,10 +183,10 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
if (!brand || typeof brand !== "number") {
setModelOptions([]);
setModel(undefined);
- return;
- }
+ return;
+ }
- try {
+ try {
setIsLoadingModels(true);
const models = await getModelsForMakeId(brand);
const options: SelectOption[] = models.map((model) => ({
@@ -161,10 +198,7 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
setModel(undefined);
} catch (error) {
console.error("Error loading models:", error);
- Alert.alert(
- "Помилка",
- "Не вдалося завантажити список моделей. Спробуйте пізніше."
- );
+ showError("Не вдалося завантажити список моделей. Спробуйте пізніше.");
setModelOptions([]);
} finally {
setIsLoadingModels(false);
@@ -172,7 +206,17 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
};
loadModels();
- }, [brand]);
+ }, [brand, showError]);
+
+ useEffect(() => {
+ if (!requiresEngineDisplacement(fuelType)) {
+ setDisplacement("");
+ }
+
+ if (!isTransmissionAllowedForFuel(fuelType, transmission)) {
+ setTransmission(undefined);
+ }
+ }, [fuelType, transmission]);
// const handleVinDecodeSuccess = async (data: {
// brand: string;
@@ -252,12 +296,26 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
// }
// };
+ const openBoughtAtPicker = () => {
+ setPickerDate(boughtAt);
+ setShowBoughtAtPicker(true);
+ };
+
+ const closeBoughtAtPicker = () => {
+ setShowBoughtAtPicker(false);
+ };
+
+ const confirmBoughtAtPicker = () => {
+ setBoughtAt(pickerDate);
+ setShowBoughtAtPicker(false);
+ };
+
const pickImage = async () => {
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.granted === false) {
- Alert.alert("Дозвіл потрібен", "Потрібен дозвіл для доступу до галереї!");
+ showError("Потрібен дозвіл для доступу до галереї!", "Дозвіл потрібен");
return;
}
@@ -283,14 +341,13 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
vin.trim() ||
carImage ||
mileage.trim() !== "" ||
- enginePower.trim() !== "" ||
- boughtAt !== null;
+ enginePower.trim() !== "";
if (hasData) {
- Alert.alert(
- "Скасувати створення?",
- "Ви вже ввели деякі дані. Ви впевнені, що хочете скасувати?",
- [
+ showAlert({
+ title: "Скасувати створення?",
+ message: "Ви вже ввели деякі дані. Ви впевнені, що хочете скасувати?",
+ buttons: [
{
text: "Продовжити редагування",
style: "cancel",
@@ -300,8 +357,8 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
style: "destructive",
onPress: () => navigation.navigate("Home"),
},
- ]
- );
+ ],
+ });
} else {
navigation.navigate("Home");
}
@@ -309,71 +366,74 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
const handleSave = async () => {
if (!brand) {
- Alert.alert("Помилка", "Будь ласка, вибери марку автомобіля");
+ showError("Будь ласка, вибери марку автомобіля");
return;
}
if (!model) {
- Alert.alert("Помилка", "Будь ласка, вибери модель автомобіля");
+ showError("Будь ласка, вибери модель автомобіля");
return;
}
if (!year) {
- Alert.alert("Помилка", "Будь ласка, вибери рік випуску");
+ showError("Будь ласка, вибери рік випуску");
return;
}
if (!color) {
- Alert.alert("Помилка", "Будь ласка, вибери колір автомобіля");
+ showError("Будь ласка, вибери колір автомобіля");
return;
}
if (!licensePlate.trim()) {
- Alert.alert("Помилка", "Будь ласка, введи номерний знак");
+ showError("Будь ласка, введи номерний знак");
return;
}
if (vin.trim().length !== 0 && vin.trim().length !== 17) {
- Alert.alert("Помилка", "VIN має містити рівно 17 символів");
+ showError("VIN має містити рівно 17 символів");
return;
}
if (!vin.trim()) {
- Alert.alert("Помилка", "Будь ласка, введи VIN номер (17 символів)");
+ showError("Будь ласка, введи VIN номер (17 символів)");
+ return;
+ }
+ if (!isValidVin(vin)) {
+ showError(VIN_VALIDATION_MESSAGE);
return;
}
if (fuelType === undefined) {
- Alert.alert("Помилка", "Будь ласка, вибери тип палива");
+ showError("Будь ласка, вибери тип палива");
return;
}
if (transmission === undefined) {
- Alert.alert("Помилка", "Будь ласка, вибери коробку передач");
+ showError("Будь ласка, вибери коробку передач");
return;
}
- if (driveType === undefined) {
- Alert.alert("Помилка", "Будь ласка, вибери тип приводу");
+ if (!isTransmissionAllowedForFuel(fuelType, transmission)) {
+ showError("Для електрокарів доступні лише автомат або CVT");
return;
}
- const displacementNum = displacement.trim() ? parseFloat(displacement.replace(",", ".")) : 0;
- if (!displacement.trim() || isNaN(displacementNum) || displacementNum <= 0) {
- Alert.alert("Помилка", "Введіть об'єм двигуна в літрах (наприклад 2.0)");
+ if (driveType === undefined) {
+ showError("Будь ласка, вибери тип приводу");
return;
}
- const engineCapacityCc = Math.round(displacementNum * 1000);
- if (engineCapacityCc <= 0 || engineCapacityCc > 10000) {
- Alert.alert("Помилка", "Об'єм двигуна має бути від 0.001 до 10 л");
+ const engineCapacityCc = resolveEngineCapacityCc(fuelType, displacement);
+ if (requiresEngineDisplacement(fuelType) && engineCapacityCc === null) {
+ showError("Введіть об'єм двигуна в літрах (наприклад 2.0)");
return;
}
const mileageNum = parseInt(mileage.trim(), 10);
if (mileage.trim() === "" || isNaN(mileageNum) || mileageNum < 0) {
- Alert.alert("Помилка", "Введіть пробіг (км)");
+ showError("Введіть пробіг (км)");
return;
}
if (mileageNum > 1000000) {
- Alert.alert("Помилка", "Пробіг не може перевищувати 1 000 000 км");
+ showError("Пробіг не може перевищувати 1 000 000 км");
return;
}
const enginePowerNum = parseInt(enginePower.trim(), 10);
if (!enginePower.trim() || isNaN(enginePowerNum) || enginePowerNum <= 0) {
- Alert.alert("Помилка", "Введіть потужність двигуна (к.с.)");
+ showError("Введіть потужність двигуна (к.с.)");
return;
}
if (enginePowerNum > 1000) {
- Alert.alert("Помилка", "Потужність не може перевищувати 1000 к.с.");
+ showError("Потужність не може перевищувати 1000 к.с.");
return;
}
@@ -384,26 +444,30 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
brandOptions.find((opt) => opt.value === brand)?.label || String(brand);
const modelLabel =
modelOptions.find((opt) => opt.value === model)?.label || String(model);
- const yearNum = typeof year === "number" ? year : parseInt(String(year), 10);
+ const yearNum =
+ typeof year === "number" ? year : parseInt(String(year), 10);
const colorLabel =
COLOR_OPTIONS.find((opt) => opt.value === color)?.label ||
String(color);
const dto: CreateVehicleDto = {
licensePlate: licensePlate.trim(),
- vin: vin.trim(),
+ vin: vin.trim().toUpperCase(),
brand: brandLabel,
model: modelLabel,
year: yearNum,
- boughtAt: boughtAt ? boughtAt.toISOString() : null,
+ boughtAt: boughtAt.toISOString(),
wheelDriveType: driveType as WheelDriveType,
- engineCapacity: engineCapacityCc,
+ engineCapacity: engineCapacityCc ?? 0,
fuelType: fuelType as FuelType,
enginePower: enginePowerNum,
color: colorLabel,
transmissionType: transmission as TransmissionType,
mileage: mileageNum,
- photoUrl: carImage && (carImage.startsWith("http") ? carImage : null) ? carImage : null,
+ photoUrl:
+ carImage && (carImage.startsWith("http") ? carImage : null)
+ ? carImage
+ : null,
};
const apiClient = createApiClient(getAccessToken);
@@ -411,7 +475,7 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
queryClient.invalidateQueries({ queryKey: queryKeys.vehicles });
- Alert.alert("Успішно!", "Автомобіль додано в базу", [
+ showSuccess("Автомобіль додано в базу", "Успішно!", [
{
text: "OK",
onPress: () => navigation.navigate("Home"),
@@ -419,20 +483,152 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
]);
} catch (error) {
console.error("Error saving vehicle:", error);
- const message = error instanceof Error ? error.message : "Не вдалося зберегти дані";
- Alert.alert("Помилка", message);
+ showError(parseApiErrorMessage(error));
} finally {
setIsLoading(false);
}
};
+ const validateStep = (step: number): boolean => {
+ if (step === 0) {
+ if (!brand) {
+ showError("Будь ласка, вибери марку автомобіля");
+ return false;
+ }
+ if (!model) {
+ showError("Будь ласка, вибери модель автомобіля");
+ return false;
+ }
+ if (!year) {
+ showError("Будь ласка, вибери рік випуску");
+ return false;
+ }
+ return true;
+ }
+
+ if (step === 1) {
+ if (!color) {
+ showError("Будь ласка, вибери колір автомобіля");
+ return false;
+ }
+ if (!licensePlate.trim()) {
+ showError("Будь ласка, введи номерний знак");
+ return false;
+ }
+ if (vin.trim().length !== 0 && vin.trim().length !== 17) {
+ showError("VIN має містити рівно 17 символів");
+ return false;
+ }
+ if (!vin.trim()) {
+ showError("Будь ласка, введи VIN номер (17 символів)");
+ return false;
+ }
+ if (!isValidVin(vin)) {
+ showError(VIN_VALIDATION_MESSAGE);
+ return false;
+ }
+ return true;
+ }
+
+ if (step === 2) {
+ if (
+ mileage.trim() === "" ||
+ isNaN(parseInt(mileage.trim(), 10)) ||
+ parseInt(mileage.trim(), 10) < 0
+ ) {
+ showError("Введіть пробіг (км)");
+ return false;
+ }
+ if (parseInt(mileage.trim(), 10) > 1000000) {
+ showError("Пробіг не може перевищувати 1 000 000 км");
+ return false;
+ }
+ return true;
+ }
+
+ if (step === 3) {
+ if (fuelType === undefined) {
+ showError("Будь ласка, вибери тип палива");
+ return false;
+ }
+ if (requiresEngineDisplacement(fuelType)) {
+ const engineCapacityCc = resolveEngineCapacityCc(fuelType, displacement);
+ if (engineCapacityCc === null) {
+ showError("Введіть об'єм двигуна в літрах (наприклад 2.0)");
+ return false;
+ }
+ }
+ const enginePowerNum = parseInt(enginePower.trim(), 10);
+ if (!enginePower.trim() || isNaN(enginePowerNum) || enginePowerNum <= 0) {
+ showError("Введіть потужність двигуна (к.с.)");
+ return false;
+ }
+ if (enginePowerNum > 1000) {
+ showError("Потужність не може перевищувати 1000 к.с.");
+ return false;
+ }
+ return true;
+ }
+
+ if (step === 4) {
+ if (transmission === undefined) {
+ showError("Будь ласка, вибери коробку передач");
+ return false;
+ }
+ if (!isTransmissionAllowedForFuel(fuelType, transmission)) {
+ showError("Для електрокарів доступні лише автомат або CVT");
+ return false;
+ }
+ if (driveType === undefined) {
+ showError("Будь ласка, вибери тип приводу");
+ return false;
+ }
+ return true;
+ }
+
+ return true;
+ };
+
+ const animateStepTransition = (nextStep: number) => {
+ Animated.sequence([
+ Animated.timing(stepAnimation, {
+ toValue: 0,
+ duration: 140,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(stepAnimation, {
+ toValue: 1,
+ duration: 220,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ setCurrentStep(nextStep);
+ };
+
+ const handleNextStep = () => {
+ if (!validateStep(currentStep)) {
+ return;
+ }
+ if (currentStep < TOTAL_STEPS - 1) {
+ animateStepTransition(currentStep + 1);
+ }
+ };
+
+ const handlePreviousStep = () => {
+ if (currentStep > 0) {
+ animateStepTransition(currentStep - 1);
+ }
+ };
+
if (isLoading) {
return (
@@ -444,268 +640,394 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
return (
-
-
- Додай свій автомобіль
-
-
- Створи картку твого авто
-
-
-
-
- {/* VIN Decode Field */}
- {/* */}
-
- {/* Car Image */}
-
-
- {carImage ? (
-
- ) : (
-
- 🚗
- Фото авто
-
- )}
-
-
+
+ Додай свій автомобіль
+ Створи картку твого авто
+
- {/* Brand Field */}
-
-
);
}
diff --git a/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx b/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx
index 9d16e9a29..292ff7f25 100644
--- a/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx
+++ b/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx
@@ -5,7 +5,6 @@ import {
TextInput,
TouchableOpacity,
Image,
- Alert,
Platform,
ActivityIndicator,
} from "react-native";
@@ -22,6 +21,7 @@ import { CarEditScreenProps } from "../../navigation/types";
import { Select, SelectOption } from "../../components/Select";
import { useVehicle, useUpdateVehicle, useDeleteVehicle } from "../../queries";
import { formatVinShort } from "../HomeScreen/homeScreenUtils";
+import { useAppAlert } from "../../components/AppAlert";
const COLOR_OPTIONS: SelectOption[] = [
{ label: "Білий", value: "white" },
@@ -44,6 +44,7 @@ export default function CarEditScreen({
}: CarEditScreenProps) {
useStatusBar();
const theme = useTheme();
+ const { showAlert, showError, showSuccess } = useAppAlert();
const { carId } = route.params;
const {
data: vehicle,
@@ -75,7 +76,7 @@ export default function CarEditScreen({
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.granted === false) {
- Alert.alert("Дозвіл потрібен", "Потрібен дозвіл для доступу до галереї!");
+ showError("Потрібен дозвіл для доступу до галереї!", "Дозвіл потрібен");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -114,18 +115,18 @@ export default function CarEditScreen({
const handleCancel = () => {
if (hasChanges()) {
- Alert.alert(
- "Скасувати зміни?",
- "Ви внесли зміни. Ви впевнені, що хочете скасувати?",
- [
+ showAlert({
+ title: "Скасувати зміни?",
+ message: "Ви внесли зміни. Ви впевнені, що хочете скасувати?",
+ buttons: [
{ text: "Продовжити редагування", style: "cancel" },
{
text: "Скасувати",
style: "destructive",
onPress: () => navigation.goBack(),
},
- ]
- );
+ ],
+ });
} else {
navigation.goBack();
}
@@ -133,16 +134,16 @@ export default function CarEditScreen({
const handleSave = async () => {
if (!licensePlate.trim()) {
- Alert.alert("Помилка", "Введіть номерний знак");
+ showError("Введіть номерний знак");
return;
}
const mileageNum = parseInt(mileage.trim(), 10);
if (mileage.trim() === "" || isNaN(mileageNum) || mileageNum < 0) {
- Alert.alert("Помилка", "Введіть коректний пробіг (км)");
+ showError("Введіть коректний пробіг (км)");
return;
}
if (mileageNum > 1000000) {
- Alert.alert("Помилка", "Пробіг не може перевищувати 1 000 000 км");
+ showError("Пробіг не може перевищувати 1 000 000 км");
return;
}
@@ -160,22 +161,22 @@ export default function CarEditScreen({
try {
await updateVehicle.mutateAsync({ vehicleId: carId, data: dto });
- Alert.alert("Успішно!", "Дані автомобіля оновлені", [
+ showSuccess("Дані автомобіля оновлені", "Успішно!", [
{ text: "OK", onPress: () => navigation.goBack() },
]);
} catch (e) {
- Alert.alert(
- "Помилка",
+ showError(
e instanceof Error ? e.message : "Не вдалося зберегти зміни"
);
}
};
const handleDelete = () => {
- Alert.alert(
- "Видалити автомобіль?",
- "Ви впевнені, що хочете видалити цей автомобіль? Цю дію неможливо скасувати.",
- [
+ showAlert({
+ title: "Видалити автомобіль?",
+ message:
+ "Ви впевнені, що хочете видалити цей автомобіль? Цю дію неможливо скасувати.",
+ buttons: [
{ text: "Скасувати", style: "cancel" },
{
text: "Видалити",
@@ -185,15 +186,14 @@ export default function CarEditScreen({
await deleteVehicle.mutateAsync(carId);
navigation.navigate("Home");
} catch (e) {
- Alert.alert(
- "Помилка",
+ showError(
e instanceof Error ? e.message : "Не вдалося видалити"
);
}
},
},
- ]
- );
+ ],
+ });
};
if (isLoadingVehicle || !vehicle) {
@@ -377,10 +377,7 @@ export default function CarEditScreen({
-
+
Скасувати
diff --git a/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx b/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx
index 07061cc25..25efd5d25 100644
--- a/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx
+++ b/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx
@@ -1,7 +1,6 @@
import React from "react";
import {
ActivityIndicator,
- Alert,
Image,
RefreshControl,
ScrollView,
@@ -34,7 +33,9 @@ import {
TRANSMISSION_LABELS,
WHEEL_DRIVE_LABELS,
} from "./homeScreenUtils";
+import { formatEngineDisplay } from "../../utils/vehicleFormUtils";
import { styles } from "./GarageContent.styles";
+import { useAppAlert } from "../../components/AppAlert";
const CAR_PLACEHOLDER_SVG = `
- Оновлення в реальному часі
+
+ Оновлення в реальному часі
+
Прийнято о {formatAcceptedAt(session.acceptedAt)}
@@ -340,7 +342,7 @@ export function ServiceStatusContent({
color={theme.colors.text.inverse}
/>
- Переглянути та затвердити перелік робіт
+ Затвердити перелік робіт
diff --git a/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts b/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts
index e8857ca82..595ef23fd 100644
--- a/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts
+++ b/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts
@@ -26,6 +26,12 @@ export const SERVICE_STATUS_STEPS: ServiceStatusStepConfig[] = [
title: "Погодження",
shortTitle: "Кошторис",
},
+ {
+ id: "waiting_parts",
+ emoji: "📦",
+ title: "Очікуємо деталей",
+ shortTitle: "Деталі",
+ },
{
id: "in_progress",
emoji: "🔧",
@@ -67,6 +73,8 @@ export function getStatusContextMessage(
return "Майстер проводить огляд. Очікуйте на результати та кошторис.";
case "approval":
return "Діагностику завершено. Перегляньте перелік робіт і затвердіть кошторис.";
+ case "waiting_parts":
+ return "Очікуємо постачання деталей. Ремонт розпочнеться одразу після їх отримання.";
case "in_progress":
return currentWork
? `Зараз виконується: ${currentWork}`
diff --git a/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx b/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx
index d2173799f..45267b91f 100644
--- a/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx
+++ b/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx
@@ -3,7 +3,6 @@ import {
View,
Text,
ActivityIndicator,
- Alert,
TouchableOpacity,
} from "react-native";
import { useMe } from "../../queries/userQueries";
@@ -11,10 +10,12 @@ import { useAuth } from "../../context/AuthContext";
import { theme } from "../../styles/theme";
import { styles } from "./UserCheckScreen.styles";
import { UserCheckScreenProps } from "../../navigation/types";
+import { useAppAlert } from "../../components/AppAlert";
export default function UserCheckScreen({ navigation }: UserCheckScreenProps) {
const { data: meData, isLoading, error } = useMe();
const { logout } = useAuth();
+ const { showAlert } = useAppAlert();
const [showSkipButton, setShowSkipButton] = useState(false);
useEffect(() => {
@@ -93,34 +94,39 @@ export default function UserCheckScreen({ navigation }: UserCheckScreenProps) {
}
// Показуємо повідомлення користувачу
- Alert.alert("Помилка", errorMessage, [
- {
- text: "Скасувати",
- style: "cancel",
- onPress: async () => {
- await logout();
- },
- },
- {
- text: buttonText,
- onPress: async () => {
- if (buttonText === "Увійти знову") {
+ showAlert({
+ title: "Помилка",
+ message: errorMessage,
+ buttons: [
+ {
+ text: "Скасувати",
+ style: "cancel",
+ onPress: async () => {
await logout();
- } else {
- // Спробувати знову - просто закриваємо Alert, query автоматично повториться
- // React Query автоматично повторить запит через retry логіку
- }
+ },
},
- },
- ]);
+ {
+ text: buttonText,
+ onPress: async () => {
+ if (buttonText === "Увійти знову") {
+ await logout();
+ } else {
+ // Спробувати знову - просто закриваємо Alert, query автоматично повториться
+ // React Query автоматично повторить запит через retry логіку
+ }
+ },
+ },
+ ],
+ });
}
- }, [error, isLoading, logout, navigation]);
+ }, [error, isLoading, logout, navigation, showAlert]);
const handleSkip = () => {
- Alert.alert(
- "Пропустити перевірку",
- "Ви впевнені, що хочете пропустити перевірку профілю? Це може призвести до проблем з функціональністю додатку.",
- [
+ showAlert({
+ title: "Пропустити перевірку",
+ message:
+ "Ви впевнені, що хочете пропустити перевірку профілю? Це може призвести до проблем з функціональністю додатку.",
+ buttons: [
{
text: "Скасувати",
style: "cancel",
@@ -130,8 +136,8 @@ export default function UserCheckScreen({ navigation }: UserCheckScreenProps) {
style: "destructive",
onPress: () => navigation.replace("ProfileSetup"),
},
- ]
- );
+ ],
+ });
};
return (
diff --git a/apps/Mobile/src/types/serviceStatus.ts b/apps/Mobile/src/types/serviceStatus.ts
index 3ee0e41ba..c4c204ebf 100644
--- a/apps/Mobile/src/types/serviceStatus.ts
+++ b/apps/Mobile/src/types/serviceStatus.ts
@@ -2,6 +2,7 @@ export type ServiceStatusId =
| "received"
| "diagnostics"
| "approval"
+ | "waiting_parts"
| "in_progress"
| "quality_check"
| "ready";
diff --git a/apps/Mobile/src/utils/apiErrorUtils.ts b/apps/Mobile/src/utils/apiErrorUtils.ts
new file mode 100644
index 000000000..824ae9775
--- /dev/null
+++ b/apps/Mobile/src/utils/apiErrorUtils.ts
@@ -0,0 +1,49 @@
+const API_ERROR_MESSAGES_UK: Record = {
+ "Vin is required.": "Введіть VIN номер.",
+ "Vin must be 17 characters long.": "VIN має містити рівно 17 символів.",
+ "Vin must not exceed 17 characters.": "VIN має містити рівно 17 символів.",
+ "Vin must be a valid 17-character VIN.":
+ "VIN має містити 17 символів: латинські літери (без I, O, Q) та цифри.",
+ "License plate is required.": "Введіть номерний знак.",
+ "License plate must not exceed 10 characters.":
+ "Номерний знак не може перевищувати 10 символів.",
+};
+
+function translateApiMessage(message: string): string {
+ return API_ERROR_MESSAGES_UK[message] ?? message;
+}
+
+export function parseApiErrorMessage(error: unknown): string {
+ if (!(error instanceof Error)) {
+ return "Не вдалося зберегти дані";
+ }
+
+ try {
+ const parsed = JSON.parse(error.message) as {
+ title?: string;
+ errors?: Record;
+ };
+
+ if (parsed.errors) {
+ const messages = Object.values(parsed.errors)
+ .flat()
+ .map(translateApiMessage);
+
+ if (messages.length > 0) {
+ return messages.join("\n");
+ }
+ }
+
+ if (parsed.title) {
+ return translateApiMessage(parsed.title);
+ }
+ } catch {
+ // Response is not JSON — use raw message below.
+ }
+
+ if (error.message.includes("401")) {
+ return "Сесія закінчилася. Будь ласка, увійдіть знову.";
+ }
+
+ return translateApiMessage(error.message);
+}
diff --git a/apps/Mobile/src/utils/vehicleFormUtils.ts b/apps/Mobile/src/utils/vehicleFormUtils.ts
new file mode 100644
index 000000000..8cc08fd74
--- /dev/null
+++ b/apps/Mobile/src/utils/vehicleFormUtils.ts
@@ -0,0 +1,134 @@
+import { SelectOption } from "../components/Select";
+import { FuelType, TransmissionType } from "../types/api";
+
+export function normalizeFuelType(
+ fuelType?: string | number | null
+): number | undefined {
+ if (fuelType === undefined || fuelType === null || fuelType === "") {
+ return undefined;
+ }
+
+ if (typeof fuelType === "number") {
+ return fuelType;
+ }
+
+ const parsed = Number(fuelType);
+ return Number.isNaN(parsed) ? undefined : parsed;
+}
+
+export function normalizeTransmissionType(
+ transmission?: string | number | null
+): number | undefined {
+ if (transmission === undefined || transmission === null || transmission === "") {
+ return undefined;
+ }
+
+ if (typeof transmission === "number") {
+ return transmission;
+ }
+
+ const parsed = Number(transmission);
+ return Number.isNaN(parsed) ? undefined : parsed;
+}
+
+export function requiresEngineDisplacement(
+ fuelType?: string | number | null
+): boolean {
+ const normalizedFuelType = normalizeFuelType(fuelType);
+
+ if (normalizedFuelType === undefined) {
+ return false;
+ }
+
+ return (
+ normalizedFuelType !== FuelType.Electric &&
+ normalizedFuelType !== FuelType.Hydrogen
+ );
+}
+
+export function getEnginePowerLabel(fuelType?: string | number | null): string {
+ const normalizedFuelType = normalizeFuelType(fuelType);
+
+ if (normalizedFuelType === FuelType.Electric) {
+ return "Потужність електромотора (к.с.) *";
+ }
+
+ if (normalizedFuelType === FuelType.Hydrogen) {
+ return "Потужність силової установки (к.с.) *";
+ }
+
+ return "Потужність двигуна (к.с.) *";
+}
+
+export function getTransmissionOptionsForFuel(
+ fuelType: string | number | undefined,
+ allOptions: SelectOption[]
+): SelectOption[] {
+ if (normalizeFuelType(fuelType) === FuelType.Electric) {
+ return allOptions.filter(
+ (option) =>
+ normalizeTransmissionType(option.value) === TransmissionType.Automatic ||
+ normalizeTransmissionType(option.value) === TransmissionType.CVT
+ );
+ }
+
+ return allOptions;
+}
+
+export function isTransmissionAllowedForFuel(
+ fuelType: string | number | undefined,
+ transmission: string | number | undefined
+): boolean {
+ if (normalizeFuelType(fuelType) !== FuelType.Electric) {
+ return true;
+ }
+
+ const normalizedTransmission = normalizeTransmissionType(transmission);
+ if (normalizedTransmission === undefined) {
+ return true;
+ }
+
+ return (
+ normalizedTransmission === TransmissionType.Automatic ||
+ normalizedTransmission === TransmissionType.CVT
+ );
+}
+
+export function resolveEngineCapacityCc(
+ fuelType: string | number,
+ displacement: string
+): number | null {
+ if (!requiresEngineDisplacement(fuelType)) {
+ return 0;
+ }
+
+ const displacementNum = displacement.trim()
+ ? parseFloat(displacement.replace(",", "."))
+ : NaN;
+
+ if (!displacement.trim() || isNaN(displacementNum) || displacementNum <= 0) {
+ return null;
+ }
+
+ const engineCapacityCc = Math.round(displacementNum * 1000);
+
+ if (engineCapacityCc <= 0 || engineCapacityCc > 10000) {
+ return null;
+ }
+
+ return engineCapacityCc;
+}
+
+export function formatEngineDisplay(
+ engineCapacity: number,
+ enginePower: number,
+ fuelType: FuelType
+): string {
+ const power = `${enginePower} к.с.`;
+
+ if (!requiresEngineDisplacement(fuelType) || engineCapacity <= 0) {
+ return power;
+ }
+
+ return `${(engineCapacity / 1000).toFixed(1)} л, ${power}`;
+}
diff --git a/apps/Mobile/src/utils/vinUtils.ts b/apps/Mobile/src/utils/vinUtils.ts
new file mode 100644
index 000000000..1c494516b
--- /dev/null
+++ b/apps/Mobile/src/utils/vinUtils.ts
@@ -0,0 +1,12 @@
+const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/i;
+
+export function normalizeVinInput(value: string): string {
+ return value.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/gi, "").slice(0, 17);
+}
+
+export function isValidVin(vin: string): boolean {
+ return VIN_REGEX.test(vin.trim());
+}
+
+export const VIN_VALIDATION_MESSAGE =
+ "VIN має містити 17 символів: латинські літери (без I, O, Q) та цифри";
diff --git a/apps/landing/assets/favicon.png b/apps/landing/assets/favicon.png
new file mode 100644
index 000000000..9b83df3a3
Binary files /dev/null and b/apps/landing/assets/favicon.png differ
diff --git a/apps/landing/assets/favicon.svg b/apps/landing/assets/favicon.svg
new file mode 100644
index 000000000..f84f8b4f5
--- /dev/null
+++ b/apps/landing/assets/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git "a/apps/landing/assets/images/crm/\320\227\320\275\321\226\320\274\320\276\320\272 \320\265\320\272\321\200\320\260\320\275\320\260 2026-07-02 \320\276 13.35.33.png" "b/apps/landing/assets/images/crm/\320\227\320\275\321\226\320\274\320\276\320\272 \320\265\320\272\321\200\320\260\320\275\320\260 2026-07-02 \320\276 13.35.33.png"
new file mode 100644
index 000000000..63108764d
Binary files /dev/null and "b/apps/landing/assets/images/crm/\320\227\320\275\321\226\320\274\320\276\320\272 \320\265\320\272\321\200\320\260\320\275\320\260 2026-07-02 \320\276 13.35.33.png" differ
diff --git "a/apps/landing/assets/images/crm/\320\227\320\275\321\226\320\274\320\276\320\272 \320\265\320\272\321\200\320\260\320\275\320\260 2026-07-02 \320\276 13.35.55.png" "b/apps/landing/assets/images/crm/\320\227\320\275\321\226\320\274\320\276\320\272 \320\265\320\272\321\200\320\260\320\275\320\260 2026-07-02 \320\276 13.35.55.png"
new file mode 100644
index 000000000..f1dd72b40
Binary files /dev/null and "b/apps/landing/assets/images/crm/\320\227\320\275\321\226\320\274\320\276\320\272 \320\265\320\272\321\200\320\260\320\275\320\260 2026-07-02 \320\276 13.35.55.png" differ
diff --git a/apps/landing/assets/images/mechanic/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.29.12.png b/apps/landing/assets/images/mechanic/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.29.12.png
new file mode 100644
index 000000000..45f1232da
Binary files /dev/null and b/apps/landing/assets/images/mechanic/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.29.12.png differ
diff --git a/apps/landing/assets/images/mechanic/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.30.11.png b/apps/landing/assets/images/mechanic/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.30.11.png
new file mode 100644
index 000000000..3c0747005
Binary files /dev/null and b/apps/landing/assets/images/mechanic/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.30.11.png differ
diff --git a/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.27.51.png b/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.27.51.png
new file mode 100644
index 000000000..1a08255ec
Binary files /dev/null and b/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.27.51.png differ
diff --git a/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.28.37.png b/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.28.37.png
new file mode 100644
index 000000000..29d96d894
Binary files /dev/null and b/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.28.37.png differ
diff --git a/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.28.49.png b/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.28.49.png
new file mode 100644
index 000000000..38abace6b
Binary files /dev/null and b/apps/landing/assets/images/user/Simulator Screenshot - iPhone 16 Pro - 2026-07-02 at 13.28.49.png differ
diff --git a/apps/landing/index.html b/apps/landing/index.html
new file mode 100644
index 000000000..1ac8b6857
--- /dev/null
+++ b/apps/landing/index.html
@@ -0,0 +1,977 @@
+
+
+
+
+
+ Rookit | Нова культура сервісного обслуговування
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Екосистема для СТО і водіїв
+ Створюємо нову культуру сервісного обслуговування
+
+ Ми будуємо екосистему цифрових взаємин між власниками авто та
+ сервісами: мобільний застосунок для водіїв, CRM для СТО та робочі
+ кабінети майстрів. Система створює довіру, підсилює співпрацю і
+ відкриває нові ринкові можливості для партнерських сервісів.
+
+
+
+ -
+ Mobile + CRM + Майстри
+ Три інструменти в одній екосистемі
+
+ -
+ Цифрова звичка клієнта
+ Регулярне використання застосунку водієм
+
+ -
+ Новий стандарт ринку
+ Перевага над традиційними методами ведення сервісу
+
+
+
+
+
+
+
+
+
+
+
+ Архітектура продукту
+ Єдина платформа для всіх учасників сервісного процесу
+
+
+
+ Мобільний застосунок водія
+
+ Функціонал достатньо об’ємний для регулярного використання, тому
+ клієнт формує цифрову звичку та тісніше прив’язується до сервісу.
+
+
+
+ CRM для сервісу
+
+ Контроль процесів, аналітика, комунікація та інструменти допродажу
+ в одному середовищі для керівника та адміністраторів.
+
+
+
+ Робочі кабінети майстрів
+
+ Оперативне ведення робіт, оновлення статусів у реальному часі та
+ зручна взаємодія з клієнтом через загальну систему.
+
+
+
+ Цифровий паспорт автомобіля
+
+ Після кожного візиту до партнерського СТО в профілі авто
+ автоматично з’являється верифікований запис обслуговування. Усі
+ підтверджені візити збираються в єдиний цифровий паспорт — цілісну
+ історію, яка зростає разом із автомобілем.
+
+
+
+
+
+
+
+
+
+ Інтерфейс продукту
+ Один екосистемний досвід у трьох інтерфейсах
+
+ Перемикайте між застосунком водія, кабінетом майстра та CRM. Натисніть
+ на екран, щоб відкрити скріншот у повному розмірі.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Мобільний застосунок
+ Застосунок для водіїв
+
+ Гараж, історія обслуговування, запис на сервіс і статуси ремонту
+ в одному зручному середовищі.
+
+
+ - Цифровий паспорт автомобіля
+ - Верифікована історія авто
+ - Realtime статуси ремонту
+ - Пошук партнерських сервісів
+
+
+
+
+
+
+
+
+
+
+ Робочий кабінет
+ Кабінет майстра
+
+ Оперативне ведення ремонту, оновлення статусів і контроль робіт
+ прямо з мобільного робочого місця.
+
+
+ - Активні роботи та черга
+ - Оновлення статусів у реальному часі
+ - Зручний мобільний сценарій для цеху
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Веб-панель СТО
+ CRM для сервісу
+
+ Керування чергою, картками авто та операційними процесами в
+ єдиному робочому середовищі для команди сервісу.
+
+
+ - Черга та статуси візитів
+ - Картки авто та історія робіт
+ - Операційна аналітика для керівника
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Для власників СТО
+ Як екосистема дає вам конкурентну перевагу
+
+
+
+ Перевага над традиційними методами сервісу
+
+ Партнерські СТО отримують сучасний цифровий підхід: прозорість,
+ керованість і верифіковану історію обслуговування для клієнта.
+
+
+
+ Клієнти з новою цифровою звичкою
+
+ Коли водій регулярно користується застосунком, він очікує аналогічно
+ високий рівень сервісу і повертається до партнерського СТО.
+
+
+
+ Прямий діалог і рекламні push
+
+ Сервісні push-сповіщення інформують клієнта про ключові етапи
+ ремонту без зайвих дзвінків. Окремо передбачені рекламні push для
+ ваших прив’язаних клієнтів: безкоштовно повідомляйте про акції та
+ послуги, допродавуючи сервіс без витрат на зовнішню рекламу.
+
+
+
+ Допродаж послуг через застосунок
+
+ Коли під час обслуговування з’являються додаткові роботи, клієнт
+ може погодити їх прямо в застосунку. Це зручніше для водія і дає
+ сервісу додаткову можливість монетизувати послуги без зайвого
+ адміністративного навантаження.
+
+
+
+ Ваша клієнтська база під захистом
+
+ Клієнти, яких ви додаєте в систему, не стають доступними іншим
+ учасникам екосистеми як чужа аудиторія. Модель підписок на СТО
+ одразу прив’язує доданого клієнта до вашої бази. Водій може, як і
+ в реальному житті, підписатися на інші сервіси або відписатися від
+ неактуальних — конкуренція залишається здоровою, а ваші зусилля з
+ залучення захищені.
+
+
+
+ Напрям додаткового доходу
+
+ Ми рухаємось у бік того, щоб екосистема допомагала сервісу
+ отримувати додатковий дохід — через розвиток застосунку для водіїв
+ і нові цифрові сценарії разом із партнерами. Це вектор, у якому
+ бачимо можливість окупити ваші вкладення в систему, а не просто отримати готовий набір
+ інструментів на сьогодні.
+
+
+
+ Підписка замість дорогої власної розробки
+
+ Подібні системи дійсно корисні для бізнесу СТО, але їх розробка
+ коштує дуже дорого. Навіть після запуску цифровий продукт і надалі
+ вимагає великих витрат лише на підтримку. Rookit дає доступ до
+ такої екосистеми за мінімальну підписку — без інвестицій у сотні
+ тисяч доларів на власну розробку та обслуговування.
+
+
+
+ Мережевий ефект кластера
+
+ Кожен новий водій, який приходить у застосунок не через окремий
+ сервіс, а через сарафанне радіо про екосистему, стає потенційним
+ клієнтом для всіх учасників кластера — без ваших витрат на
+ залучення. Мережевий ефект працює в обидва боки: кожен новий клієнт
+ і кожен новий партнерський СТО підсилюють цінність платформи для
+ всіх інших.
+
+
+
+
+
+
+
+
+
+ Ключова стратегія платформи
+ Чому спільна екосистема виграє у довгостроковій перспективі
+
+
+
+ "Власний застосунок під ваш бренд — це витрати без результату. Спільна екосистема — це клієнт, який повертається сам."
+
+ Ми не робимо копію продукту під кожне СТО. Єдиний мобільний
+ застосунок об’єднує водіїв і партнерські сервіси на рівних умовах:
+ водій отримує зручність одного цифрового середовища, а партнери -
+ стійкий доступ до аудиторії та перевагу над сервісами поза екосистемою.
+
+
+
+
+
+ 1) Проблема уваги
+
+ Водій не триматиме в телефоні 10 різних застосунків від 10 СТО.
+ Але один застосунок, де "живе" його авто, залишається з ним
+ надовго. Ваш бренд присутній у середовищі, куди клієнт реально
+ повертається.
+
+
+
+
+ 2) Історія, що подорожує з клієнтом
+
+ Водій не прив’язаний до одного сервісу: за потреби він може
+ звертатися до будь-якого партнера екосистеми. Переїхавши в інше
+ місто, клієнт зберігає всю накопичену історію, яка й надалі
+ поповнюється в інших партнерських СТО. Автомобіль «живе» в
+ застосунку — і вихід із системи означає втрату цілісності цієї
+ історії, тому мотивація залишатися в екосистемі значно вища.
+
+
+
+
+ 3) Кластерний ефект партнерів
+
+ Партнерські СТО формують єдиний кластер, який разом залучає й
+ утримує аудиторію водіїв. Кожен новий партнер підсилює платформу,
+ а кожен новий водій підвищує цінність для всіх учасників.
+
+
+
+
+ Один застосунок — звичка і лояльність
+
+ Один зрозумілий продукт простіше запам’ятати і регулярно
+ використовувати. Водій заходить у застосунок не лише під час
+ поломки — перевіряє історію, шукає сервіси, взаємодіє з авто
+ щоденно. Кожен вхід підсилює звичку і підвищує ймовірність
+ повернення до партнерського СТО.
+
+
+
+
+
+ Новий водій у екосистемі — ваш потенційний клієнт без витрат на залучення
+
+ Кожен водій, який реєструється в спільній екосистемі, отримує доступ
+ до партнерських сервісів у одному застосунку. Для СТО це означає нову
+ аудиторію без окремих зусиль і бюджету на власний застосунок чи
+ рекламу: клієнт уже в системі, де ваш сервіс може стати його вибором.
+
+
+
+
+ Самопідсилювана екосистема
+
+ Rookit — не набір розрізнених інструментів, а взаємопов’язана система,
+ де кожен елемент підсилює інший. З кожною взаємодією в застосунку,
+ CRM або кабінеті майстра зростає загальна цінність платформи для всіх
+ учасників. Це дає перевагу над вузькими нішевими рішеннями: ми
+ запускаємо маховик, де кожен компонент працює на залучення,
+ нагадування та продаж ваших послуг клієнтам. Паралельно ми рухаємось
+ у напрямку функціоналу, який допомагає сервісу окуповувати вкладення
+ в цифрову систему.
+
+
+
+
+
+
+
+
+ Цінність для водія
+ Регулярний корисний досвід, що формує довіру до сервісу
+
+
+
+
+
+
+
+ Цифровий паспорт і верифікована історія
+
+ Партнерське СТО підтверджує виконані роботи, і запис автоматично
+ потрапляє в цифровий паспорт авто. Водій також може самостійно
+ додавати власні записи — наприклад, про ремонт вдома. Але лише
+ історія, верифікована сервісом, має статус офіційної — саме вона
+ формує рейтинг автомобіля, корисний при продажі.
+
+
+
+
+
+
+
+
+ Realtime статуси ремонту
+
+ Клієнт бачить процес ремонту в реальному часі через статуси в
+ додатку, що знижує тривожність і кількість уточнюючих дзвінків.
+
+
+
+
+
+
+
+
+ Застосунок, куди хочеться повертатися
+
+ Об’ємний корисний функціонал підтримує регулярне використання
+ та формує довгострокову прив’язку водія до партнерського сервісу.
+
+
+
+
+
+
+
+
+ Рейтинг авто на основі офіційної історії
+
+ Різниця між власними записами та верифікованою історією мотивує
+ водіїв регулярніше обслуговуватися у партнерських СТО. Накопичена
+ офіційна історія формує рейтинг автомобіля, що підвищує довіру
+ покупця та потенційну вартість авто на вторинному ринку.
+
+
+
+
+
+
+
+
+
+
+ CRM та робочі кабінети
+ Функціонал, який підсилює операційку сервісу щодня
+
+
+
+ Що отримує сервіс у CRM
+
+ CRM проектується як практичний інструмент для щоденної роботи
+ сервісу, а не просто як "вітрина" процесів.
+
+
+ - аналітика по роботах та процесах;
+ - трекінг виконання у реальному часі;
+ - оперативне погодження та допродаж послуг.
+
+
+
+ Монетизація і швидкість реакції
+
+ Система відкриває нові точки доходу, зокрема в роботі з
+ "терміновими" клієнтами, де важлива швидкість і прозорість рішень.
+
+
+ - менше простоїв у комунікації;
+ - більше погоджених робіт у моменті;
+ - контроль якості сервісу через цифрові сценарії.
+
+
+
+
+
+
+
+
+
+ Ринкове бачення
+ Екосистема, яка змінює стандарт взаємодії на ринку
+
+
+
+ Цифрова дисципліна обслуговування
+
+ Водій бачить прозорий процес, сервіс отримує керовані операції, а
+ довіра між сторонами стає частиною щоденної взаємодії.
+
+
+
+ Стійка лояльність клієнтів
+
+ Коли клієнт має цифрову історію та зрозумілий сервісний досвід,
+ він частіше повертається саме до партнерського СТО.
+
+
+
+ Самопідсилювана цінність платформи
+
+ Кожна взаємодія в екосистемі — візит до СТО, оновлення статусу,
+ перегляд історії — підсилює загальну цінність Rookit для всіх
+ учасників. Система працює як маховик: більше користувачів — сильніша
+ платформа — більше можливостей для кожного партнера.
+
+
+
+
+
+
+
+
+
+ Швидкий старт
+ Запуск і впровадження проходять просто та без затягувань
+
+
+
+ Легкий онбординг команди
+
+ Інтерфейси CRM і кабінетів майстрів інтуїтивні, тому персонал
+ швидко адаптується до нових процесів без складного навчання.
+
+
+ - зрозуміла структура робочих екранів;
+ - швидкий перехід до щоденних задач;
+ - мінімальний поріг входу для команди.
+
+
+
+ Операційний ефект з перших тижнів
+
+ Сервіс швидко отримує контрольовані цифрові сценарії, менше
+ хаосу в комунікації та вищу передбачуваність роботи з клієнтами.
+
+
+ - швидкий старт без складних інтеграційних бар'єрів;
+ - прозорий контроль статусів та завантаження;
+ - приріст лояльності за рахунок якісного сервісного досвіду.
+
+
+
+
+
+
+
+
+
+ FAQ
+ Поширені питання власників СТО
+
+ Короткі відповіді про те, як Rookit працює для сервісу, команди та
+ ваших клієнтів у щоденній операційці.
+
+
+
+
+ Що таке Rookit і чим він відрізняється від звичайної CRM?
+
+ Rookit — це не окрема CRM «всередині сервісу», а єдина екосистема з
+ трьох частин: мобільний застосунок для водія, CRM для керівника та
+ адміністраторів і робочі кабінети для майстрів. Коли клієнт бачить
+ статус ремонту, історію авто та сервіс у застосунку, а ваша команда
+ веде ті самі процеси в CRM і в цеху — це один синхронізований
+ досвід, а не розрізнені інструменти.
+
+
+
+ Чому ви не робите окремий застосунок під мій бренд СТО?
+
+ Водій не буде тримати в телефоні десяток застосунків від різних
+ сервісів — він залишить один або не завантажить жодного. Спільна
+ екосистема вирішує це: один застосунок, де «живе» авто клієнта,
+ куди він повертається регулярно. Ваш сервіс присутній у цьому
+ середовищі, а не в окремому застосунку, про який клієнт забуде після візиту.
+ Власний брендовий застосунок для більшості СТО — це високі витрати
+ на розробку і просування без гарантії регулярного використання.
+
+
+
+ Яку конкретну користь отримує моє СТО від спільної платформи?
+
+ Партнерський сервіс отримує перевагу над традиційними форматами
+ роботи: прозорі процеси для клієнта, верифіковану історію
+ обслуговування, керованість операцій у CRM і доступ до аудиторії
+ водіїв, які уже користуються екосистемою. Кожен новий водій у
+ системі — потенційний клієнт вашого сервісу без окремих витрат на
+ власний застосунок чи рекламу для залучення.
+
+
+
+ Що саме дає CRM у щоденній роботі сервісу?
+
+ CRM закриває операційку керівника та адміністраторів: черга і
+ статуси візитів, картки авто, історія робіт, аналітика по процесах
+ і трекінг виконання в реальному часі. Через систему швидше
+ погоджуються роботи, зручніше допродавати послуги в моменті та
+ контролювати завантаження сервісу без хаосу в комунікації.
+
+
+
+ Навіщо окремий кабінет для майстрів?
+
+ Майстер оновлює статуси ремонту прямо з цеху — без зайвих дзвінків
+ адміністратору і без затримок. Клієнт одразу бачить зміни в
+ застосунку, а керівник — актуальну картину по роботах у CRM.
+ Це зменшує кількість уточнюючих дзвінків від клієнтів і пришвидшує
+ проходження авто через сервіс.
+
+
+
+ Що таке цифровий паспорт автомобіля?
+
+ Це єдиний цифровий профіль авто, куди автоматично потрапляють
+ верифіковані записи після кожного візиту до партнерського СТО.
+ Історія збирається в одному місці, зростає з кожним обслуговуванням
+ і стає цінним активом для водія — зокрема при продажі автомобіля.
+
+
+
+ Чи бачать інші СТО моїх клієнтів?
+
+ Ні. Клієнти, яких ви додаєте в систему, прив’язуються до вашої бази
+ через модель підписок на СТО і не стають доступними іншим учасникам
+ як чужа аудиторія. Водій може самостійно підписатися на інші
+ партнерські сервіси або відписатися від неактуальних — як у реальному
+ житті, зі збереженням здорової конкуренції.
+
+
+
+ Чи може клієнт обслуговуватися в іншому місті?
+
+ Так. Водій не обмежений одним сервісом і може звертатися до будь-якого
+ партнера екосистеми. Уся накопичена історія зберігається і продовжує
+ поповнюватися в нових партнерських СТО. Це підвищує цінність
+ залишатися в системі: автомобіль «живе» в застосунку, і вихід із
+ нього означає втрату цілісності накопиченої історії.
+
+
+
+ Як верифікована історія допомагає утримувати клієнтів?
+
+ Після візиту до партнерського СТО у профілі авто з’являється
+ підтверджений запис про виконані роботи. Для водія це цифровий
+ актив: історія зростає з кожним обслуговуванням, і клієнту невигідно
+ втрачати її цілісність, переходячи на сервіс поза екосистемою.
+ Для вас це інструмент retention — клієнт із цифровою звичкою
+ частіше повертається саме до партнерського сервісу.
+
+
+
+ Чи підходить Rookit невеликій майстерні, а не лише великим мережам?
+
+ Так. Власна розробка подібної системи коштує дуже дорого, а після
+ запуску потребує ще й постійних витрат на підтримку. Rookit відкриває
+ доступ до готової екосистеми за мінімальну підписку — без інвестицій
+ у сотні тисяч доларів. Тому і невелика майстерня, і більший сервіс
+ отримують однаково потужний цифровий інструментарій.
+
+
+
+ Як realtime-статуси ремонту впливають на роботу сервісу?
+
+ Клієнт бачить етапи ремонту в застосунку й рідше дзвонить з питанням
+ «що там з моїм авто». Менше навантаження на адміністраторів,
+ менше тривожності з боку клієнта, швидше погодження додаткових робіт,
+ коли вони з’являються в процесі. Для сервісу це і економія часу
+ команди, і вищий рівень довіри до вашого бренду.
+
+
+
+ Звідки беруться нові клієнти для партнерського СТО?
+
+ Водії реєструються в єдиній екосистемі, шукають сервіси, ведуть
+ гараж і історію авто в одному застосунку. Партнерські СТО
+ формують кластер, який разом залучає й утримує аудиторію: кожен
+ новий водій підсилює цінність платформи для всіх учасників, а ваш
+ сервіс отримує доступ до клієнтів, які вже звикли до цифрового
+ формату обслуговування.
+
+
+
+ Наскільки складно запустити систему в моєму сервісі?
+
+ Запуск і впровадження розраховані на швидкий старт: інтерфейси CRM
+ і кабінетів майстрів інтуїтивні, команда адаптується без довгого
+ навчання, а цифрові сценарії дають операційний ефект уже в перші
+ тижні — прозорий контроль статусів, менше хаосу в комунікації та
+ передбачуваніша робота з клієнтами.
+
+
+
+ Чим відрізняється офіційна історія від записів, доданих водієм?
+
+ Водій може безкоштовно додавати власні записи — наприклад, про ремонт,
+ виконаний самостійно. Але лише історія, верифікована партнерським
+ СТО, отримує статус офіційної. Саме офіційні записи впливають на
+ рейтинг автомобіля, що мотивує водіїв регулярніше обслуговуватися
+ у партнерських сервісах і підвищує цінність авто при продажі.
+
+
+
+ Які нові можливості монетизації відкриває платформа?
+
+ Наразі це насамперед напрям розвитку, а не окремий модуль «з коробки».
+ Ми орієнтуємось на те, щоб застосунок для водіїв поступово
+ поповнювався новими цифровими сценаріями та послугами партнерських
+ СТО — із фокусом на форматах, які можуть приносити сервісу додатковий
+ дохід і допомагати окуповувати вкладення в платформу. Конкретні
+ механіки будуть формуватися та перевірятися разом із ринком.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ![]()
+
+
+
+
+
+
+
+
+
diff --git a/apps/landing/script.js b/apps/landing/script.js
new file mode 100644
index 000000000..581bcdb9d
--- /dev/null
+++ b/apps/landing/script.js
@@ -0,0 +1,165 @@
+const menuToggle = document.querySelector("[data-menu-toggle]");
+const menu = document.querySelector("[data-menu]");
+
+if (menuToggle && menu) {
+ menuToggle.addEventListener("click", () => {
+ const isOpen = menu.classList.toggle("open");
+ menuToggle.setAttribute("aria-expanded", String(isOpen));
+ });
+
+ menu.querySelectorAll("a").forEach((link) => {
+ link.addEventListener("click", () => {
+ menu.classList.remove("open");
+ menuToggle.setAttribute("aria-expanded", "false");
+ });
+ });
+}
+
+const showcaseTabs = Array.from(document.querySelectorAll("[data-showcase-tab]"));
+const showcasePanels = Array.from(document.querySelectorAll("[data-showcase-panel]"));
+
+showcaseTabs.forEach((tab) => {
+ tab.addEventListener("click", () => {
+ const target = tab.dataset.showcaseTab;
+
+ showcaseTabs.forEach((item) => {
+ const isActive = item === tab;
+ item.classList.toggle("is-active", isActive);
+ item.setAttribute("aria-selected", String(isActive));
+ });
+
+ showcasePanels.forEach((panel) => {
+ panel.classList.toggle("is-active", panel.dataset.showcasePanel === target);
+ });
+ });
+});
+
+document.querySelectorAll("[data-showcase-thumb]").forEach((thumb) => {
+ thumb.addEventListener("click", () => {
+ const panel = thumb.closest("[data-showcase-panel]");
+ const mainButton = panel?.querySelector("[data-showcase-main]");
+ const mainImage = mainButton?.querySelector(".device-frame img");
+ const thumbImage = thumb.querySelector("img");
+
+ if (!mainImage || !thumbImage) {
+ return;
+ }
+
+ mainImage.src = thumbImage.currentSrc || thumbImage.src;
+ mainImage.alt = thumbImage.alt || mainImage.alt;
+
+ panel.querySelectorAll("[data-showcase-thumb]").forEach((item) => {
+ item.classList.toggle("is-active", item === thumb);
+ });
+ });
+});
+
+const lightbox = document.getElementById("lightbox");
+const lightboxImage = lightbox?.querySelector(".lightbox-image");
+const lightboxCaption = lightbox?.querySelector(".lightbox-caption");
+const lightboxCounter = lightbox?.querySelector(".lightbox-counter");
+const lightboxItems = Array.from(document.querySelectorAll("[data-lightbox-index]")).map(
+ (trigger) => {
+ const image = trigger.querySelector("img");
+ const panel = trigger.closest("[data-showcase-panel]");
+ const caption =
+ panel?.querySelector("[data-showcase-main]")?.dataset.caption ||
+ image?.alt ||
+ "";
+
+ return {
+ src: image?.currentSrc || image?.src || "",
+ alt: image?.alt || "",
+ caption,
+ };
+ }
+);
+
+let lightboxIndex = 0;
+let lastFocusedElement = null;
+
+function renderLightboxItem(index) {
+ if (!lightboxItems.length || !lightboxImage || !lightboxCaption || !lightboxCounter) {
+ return;
+ }
+
+ const normalizedIndex = (index + lightboxItems.length) % lightboxItems.length;
+ const item = lightboxItems[normalizedIndex];
+
+ lightboxIndex = normalizedIndex;
+ lightboxImage.src = item.src;
+ lightboxImage.alt = item.alt;
+ lightboxCaption.textContent = item.caption;
+ lightboxCounter.textContent = `${normalizedIndex + 1} / ${lightboxItems.length}`;
+}
+
+function openLightbox(index) {
+ if (!lightbox || !lightboxItems.length) {
+ return;
+ }
+
+ lastFocusedElement = document.activeElement;
+ renderLightboxItem(index);
+
+ lightbox.hidden = false;
+ lightbox.setAttribute("aria-hidden", "false");
+ document.body.style.overflow = "hidden";
+
+ lightbox.querySelector(".lightbox-close")?.focus();
+}
+
+function closeLightbox() {
+ if (!lightbox || !lightboxImage) {
+ return;
+ }
+
+ lightbox.hidden = true;
+ lightbox.setAttribute("aria-hidden", "true");
+ document.body.style.overflow = "";
+
+ lightboxImage.removeAttribute("src");
+ lastFocusedElement?.focus();
+}
+
+function showNextLightboxItem(step) {
+ renderLightboxItem(lightboxIndex + step);
+}
+
+document.querySelectorAll("[data-lightbox-open]").forEach((trigger) => {
+ trigger.addEventListener("click", () => {
+ const panel = trigger.closest("[data-showcase-panel]");
+ const activeThumb = panel?.querySelector("[data-showcase-thumb].is-active");
+ const index = Number(activeThumb?.dataset.lightboxIndex ?? 0);
+ openLightbox(index);
+ });
+});
+
+lightbox?.querySelectorAll("[data-lightbox-close]").forEach((button) => {
+ button.addEventListener("click", closeLightbox);
+});
+
+lightbox
+ ?.querySelector("[data-lightbox-prev]")
+ ?.addEventListener("click", () => showNextLightboxItem(-1));
+
+lightbox
+ ?.querySelector("[data-lightbox-next]")
+ ?.addEventListener("click", () => showNextLightboxItem(1));
+
+document.addEventListener("keydown", (event) => {
+ if (!lightbox || lightbox.hidden) {
+ return;
+ }
+
+ if (event.key === "Escape") {
+ closeLightbox();
+ }
+
+ if (event.key === "ArrowLeft") {
+ showNextLightboxItem(-1);
+ }
+
+ if (event.key === "ArrowRight") {
+ showNextLightboxItem(1);
+ }
+});
diff --git a/apps/landing/styles.css b/apps/landing/styles.css
new file mode 100644
index 000000000..0a5b571b0
--- /dev/null
+++ b/apps/landing/styles.css
@@ -0,0 +1,1020 @@
+:root {
+ --bg-page: #0d1117;
+ --bg-card: #1a1c1e;
+ --bg-surface: #232527;
+ --text-primary: #f0f6fc;
+ --text-secondary: #8b949e;
+ --text-muted: #6e7681;
+ --accent-primary: #4ade9e;
+ --accent-hover: #3bc98e;
+ --accent-dark: #1a4038;
+ --accent-warning: #d29922;
+ --border-main: #30363d;
+ --radius-sm: 10px;
+ --radius-md: 16px;
+ --radius-lg: 24px;
+ --shadow-card: 0 14px 36px rgba(0, 0, 0, 0.35);
+ --container-width: 1180px;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: radial-gradient(circle at top right, #111822, #0d1117 45%);
+ color: var(--text-primary);
+ line-height: 1.55;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.container {
+ width: min(var(--container-width), calc(100% - 40px));
+ margin: 0 auto;
+}
+
+.section {
+ padding: 84px 0;
+ position: relative;
+}
+
+.section.alt {
+ background: linear-gradient(
+ 160deg,
+ rgba(26, 28, 30, 0.72),
+ rgba(13, 17, 23, 0.8) 55%
+ );
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ backdrop-filter: blur(16px);
+ background: rgba(13, 17, 23, 0.82);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.header-inner {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 74px;
+ gap: 16px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: 0.3px;
+ color: var(--accent-primary);
+}
+
+.brand-logo {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ object-fit: contain;
+}
+
+.header-nav {
+ display: flex;
+ align-items: center;
+ gap: clamp(10px, 1.2vw, 18px);
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.header-nav a:hover {
+ color: var(--text-primary);
+}
+
+.header-nav .cta-link {
+ margin-left: 4px;
+ padding: 9px 14px;
+ border-radius: 10px;
+ background: var(--accent-primary);
+ color: #10171c !important;
+ font-weight: 600;
+}
+
+.header-nav .cta-link:hover {
+ background: var(--accent-hover);
+ color: #10171c !important;
+}
+
+.menu-toggle {
+ display: none;
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 10px;
+ color: var(--text-primary);
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ gap: 4px;
+ cursor: pointer;
+}
+
+.menu-toggle span {
+ width: 18px;
+ height: 2px;
+ background-color: currentColor;
+}
+
+.hero {
+ padding-top: 74px;
+}
+
+.hero-grid {
+ display: grid;
+ grid-template-columns: 1.15fr 0.85fr;
+ gap: 28px;
+}
+
+.eyebrow {
+ color: var(--accent-primary);
+ text-transform: uppercase;
+ letter-spacing: 0.11em;
+ font-size: 12px;
+ font-weight: 700;
+ margin: 0 0 14px;
+}
+
+h1,
+h2,
+h3 {
+ margin: 0 0 12px;
+ line-height: 1.2;
+}
+
+h1 {
+ font-size: clamp(34px, 4.6vw, 56px);
+ max-width: 12.5ch;
+ font-weight: 800;
+}
+
+h2 {
+ font-size: clamp(28px, 3.4vw, 42px);
+ font-weight: 700;
+}
+
+h3 {
+ font-size: 22px;
+ font-weight: 650;
+}
+
+.lead {
+ color: var(--text-secondary);
+ max-width: 65ch;
+ font-size: 18px;
+}
+
+.hero-actions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 28px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: 14px;
+ padding: 13px 18px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: 0.2s ease;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: #10171c;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ border: 1px solid var(--border-main);
+}
+
+.btn-secondary:hover {
+ border-color: var(--accent-primary);
+}
+
+.quick-metrics {
+ margin: 30px 0 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.quick-metrics li {
+ background: rgba(35, 37, 39, 0.7);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: var(--radius-sm);
+ padding: 12px;
+}
+
+.quick-metrics strong {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--text-primary);
+ font-size: 14px;
+}
+
+.quick-metrics span {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.hero-panel {
+ position: relative;
+ overflow: visible;
+ background: linear-gradient(180deg, rgba(26, 64, 56, 0.32), rgba(26, 28, 30, 0.95));
+ border: 1px solid rgba(74, 222, 158, 0.24);
+ border-radius: var(--radius-lg);
+ padding: 28px;
+ box-shadow: var(--shadow-card);
+}
+
+.hero-mark {
+ position: absolute;
+ right: -75px;
+ bottom: -80px;
+ width: 250px;
+ height: auto;
+ pointer-events: none;
+ user-select: none;
+ z-index: 0;
+ filter: drop-shadow(0 0 28px rgba(74, 222, 158, 0.28));
+}
+
+.panel-label {
+ position: relative;
+ z-index: 1;
+ display: inline-block;
+ background: rgba(74, 222, 158, 0.13);
+ color: var(--accent-primary);
+ padding: 5px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ margin-bottom: 16px;
+}
+
+.hero-panel p {
+ color: var(--text-secondary);
+}
+
+.hero-panel h2,
+.hero-panel p,
+.status-stack {
+ position: relative;
+ z-index: 1;
+}
+
+.status-stack {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.status-pill {
+ border-radius: 999px;
+ padding: 7px 12px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.status-pill--success {
+ background: rgba(74, 222, 158, 0.15);
+ color: var(--accent-primary);
+}
+
+.status-pill--info {
+ background: rgba(88, 166, 255, 0.14);
+ color: #58a6ff;
+}
+
+.status-pill--warning {
+ background: rgba(210, 153, 34, 0.15);
+ color: var(--accent-warning);
+}
+
+.section-head {
+ margin-bottom: 30px;
+}
+
+.section-head h2 {
+ max-width: 18ch;
+}
+
+.section-head--wide h2 {
+ max-width: 28ch;
+}
+
+.section-lead {
+ margin: 0;
+ color: var(--text-secondary);
+ max-width: 52ch;
+}
+
+.product-showcase {
+ background: linear-gradient(180deg, rgba(26, 28, 30, 0.95), rgba(13, 17, 23, 0.9));
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-lg);
+ padding: 18px;
+ box-shadow: var(--shadow-card);
+}
+
+.showcase-tabs {
+ display: inline-flex;
+ gap: 8px;
+ padding: 6px;
+ margin-bottom: 18px;
+ background: rgba(35, 37, 39, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 999px;
+}
+
+.showcase-tab {
+ border: 0;
+ background: transparent;
+ color: var(--text-secondary);
+ border-radius: 999px;
+ padding: 10px 18px;
+ font: inherit;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.2s ease, color 0.2s ease;
+}
+
+.showcase-tab:hover {
+ color: var(--text-primary);
+}
+
+.showcase-tab.is-active {
+ background: var(--accent-dark);
+ color: var(--accent-primary);
+ box-shadow: inset 0 0 0 1px rgba(74, 222, 158, 0.25);
+}
+
+.showcase-panels {
+ position: relative;
+}
+
+.showcase-panel {
+ display: none;
+ grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
+ gap: 28px;
+ align-items: center;
+ padding: 8px 6px 12px;
+}
+
+.showcase-panel.is-active {
+ display: grid;
+}
+
+.showcase-label {
+ margin: 0 0 8px;
+ color: var(--accent-primary);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.showcase-copy h3 {
+ margin-bottom: 10px;
+ font-size: clamp(24px, 3vw, 32px);
+}
+
+.showcase-copy > p {
+ margin: 0 0 16px;
+ color: var(--text-secondary);
+ max-width: 42ch;
+}
+
+.showcase-points {
+ margin: 0 0 20px;
+ padding-left: 18px;
+ color: var(--text-secondary);
+}
+
+.showcase-points li + li {
+ margin-top: 6px;
+}
+
+.showcase-thumbs {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.showcase-thumb {
+ width: 64px;
+ aspect-ratio: 9 / 19.5;
+ padding: 0;
+ border-radius: 12px;
+ border: 2px solid rgba(255, 255, 255, 0.08);
+ background: var(--bg-surface);
+ overflow: hidden;
+ cursor: pointer;
+ transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.showcase-thumb:hover {
+ transform: translateY(-2px);
+ border-color: rgba(74, 222, 158, 0.35);
+}
+
+.showcase-thumb.is-active {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(74, 222, 158, 0.14);
+}
+
+.showcase-thumb img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.showcase-thumbs--desktop .showcase-thumb {
+ width: 128px;
+ aspect-ratio: 16 / 10;
+ border-radius: 10px;
+}
+
+.showcase-thumbs--desktop .showcase-thumb img {
+ object-fit: contain;
+ background: #0d1117;
+}
+
+.showcase-visual {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 420px;
+ padding: 12px;
+ border-radius: 20px;
+}
+
+.showcase-main {
+ position: relative;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: zoom-in;
+}
+
+.showcase-main:hover .showcase-expand {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.device-frame {
+ position: relative;
+ overflow: hidden;
+ background: #0d1117;
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
+}
+
+.device-frame--phone {
+ width: min(270px, 72vw);
+ border-radius: 34px;
+ border: 2px solid rgba(255, 255, 255, 0.12);
+ padding: 12px 10px 14px;
+}
+
+.device-notch {
+ width: 96px;
+ height: 22px;
+ margin: 0 auto 10px;
+ border-radius: 0 0 14px 14px;
+ background: #0d1117;
+}
+
+.device-frame--phone img {
+ display: block;
+ width: 100%;
+ border-radius: 24px;
+}
+
+.device-frame--desktop {
+ width: min(560px, 92vw);
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.device-browser-bar {
+ display: flex;
+ gap: 6px;
+ padding: 12px 14px;
+ background: #161b22;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.device-browser-bar span {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.device-frame--desktop img {
+ display: block;
+ width: 100%;
+}
+
+.showcase-expand {
+ position: absolute;
+ left: 50%;
+ bottom: 18px;
+ transform: translate(-50%, 8px);
+ opacity: 0;
+ padding: 8px 12px;
+ border-radius: 999px;
+ background: rgba(13, 17, 23, 0.82);
+ border: 1px solid rgba(74, 222, 158, 0.35);
+ color: var(--accent-primary);
+ font-size: 12px;
+ font-weight: 600;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+ pointer-events: none;
+}
+
+.lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+}
+
+.lightbox[hidden] {
+ display: none;
+}
+
+.lightbox-backdrop {
+ position: absolute;
+ inset: 0;
+ border: 0;
+ background: rgba(0, 0, 0, 0.9);
+ cursor: zoom-out;
+}
+
+.lightbox-shell {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 20px;
+}
+
+.lightbox-toolbar {
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: rgba(26, 28, 30, 0.92);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.lightbox-caption {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.lightbox-counter {
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.lightbox-close,
+.lightbox-nav {
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: rgba(35, 37, 39, 0.96);
+ color: var(--text-primary);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+}
+
+.lightbox-close:hover,
+.lightbox-nav:hover {
+ border-color: rgba(74, 222, 158, 0.45);
+ background: rgba(26, 64, 56, 0.55);
+}
+
+.lightbox-close {
+ width: 40px;
+ height: 40px;
+ font-size: 24px;
+ line-height: 1;
+}
+
+.lightbox-stage {
+ flex: 1;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 14px;
+}
+
+.lightbox-image-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 0;
+ height: 100%;
+}
+
+.lightbox-image {
+ max-width: 100%;
+ max-height: calc(100vh - 140px);
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ border-radius: 12px;
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
+}
+
+.lightbox-nav {
+ width: 44px;
+ height: 44px;
+ font-size: 28px;
+ line-height: 1;
+}
+
+@media (max-width: 1040px) {
+ .showcase-panel,
+ .showcase-panel.is-active {
+ grid-template-columns: 1fr;
+ }
+
+ .showcase-visual {
+ min-height: 360px;
+ }
+}
+
+@media (max-width: 820px) {
+ .showcase-tabs {
+ display: flex;
+ width: 100%;
+ overflow-x: auto;
+ }
+
+ .showcase-tab {
+ flex: 1 0 auto;
+ }
+
+ .lightbox-shell {
+ padding: 14px;
+ }
+
+ .lightbox-stage {
+ grid-template-columns: 1fr;
+ justify-items: center;
+ align-content: center;
+ }
+
+ .lightbox-nav {
+ display: none;
+ }
+
+ .lightbox-image {
+ max-height: calc(100vh - 120px);
+ }
+}
+
+.cards-grid,
+.vision-grid {
+ display: grid;
+ gap: 16px;
+}
+
+.cards-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.value-card,
+.vision-card,
+.split-card,
+.faq-list details {
+ background-color: var(--bg-card);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: var(--radius-md);
+ padding: 22px;
+}
+
+.value-card p,
+.vision-card p,
+.split-card p,
+.faq-list p {
+ color: var(--text-secondary);
+}
+
+.timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.timeline-item {
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ gap: 16px;
+ padding-bottom: 28px;
+}
+
+.timeline-item:last-child {
+ padding-bottom: 0;
+}
+
+.timeline-item:last-child .timeline-marker::after {
+ display: none;
+}
+
+.timeline-marker {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ padding-top: 7px;
+}
+
+.timeline-marker::after {
+ content: "";
+ position: absolute;
+ top: 24px;
+ bottom: -28px;
+ left: 50%;
+ width: 2px;
+ transform: translateX(-50%);
+ background: rgba(74, 222, 158, 0.22);
+}
+
+.timeline .dot {
+ position: relative;
+ z-index: 1;
+ display: block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--accent-primary);
+ box-shadow: 0 0 0 5px #14181d;
+}
+
+.timeline h3 {
+ margin-bottom: 8px;
+}
+
+.timeline p {
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.split {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.split-card ul {
+ margin: 0;
+ padding-left: 18px;
+ color: var(--text-secondary);
+}
+
+.ecosystem-thesis {
+ margin-bottom: 18px;
+}
+
+.marketing-engine {
+ margin-top: 18px;
+ border-color: rgba(74, 222, 158, 0.3);
+ background: linear-gradient(180deg, rgba(26, 64, 56, 0.2), rgba(26, 28, 30, 0.95));
+}
+
+.growth-engine {
+ margin-top: 18px;
+ border-color: rgba(88, 166, 255, 0.28);
+ background: linear-gradient(180deg, rgba(26, 40, 64, 0.22), rgba(26, 28, 30, 0.95));
+}
+
+.vision-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.faq-list {
+ display: grid;
+ gap: 12px;
+}
+
+.faq-list summary {
+ cursor: pointer;
+ list-style: none;
+ font-weight: 600;
+}
+
+.faq-list summary::-webkit-details-marker {
+ display: none;
+}
+
+.faq-list details[open] {
+ border-color: rgba(74, 222, 158, 0.4);
+}
+
+.faq-list p {
+ margin: 10px 0 0;
+}
+
+.site-footer {
+ padding-top: 62px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.footer-inner {
+ display: grid;
+ grid-template-columns: 1fr minmax(280px, 420px);
+ gap: 24px;
+ align-items: start;
+}
+
+.footer-inner p {
+ color: var(--text-secondary);
+}
+
+.demo-form {
+ background: var(--bg-card);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: var(--radius-md);
+ padding: 20px;
+ display: grid;
+ gap: 14px;
+}
+
+.demo-form label {
+ display: grid;
+ gap: 6px;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.demo-form input,
+.demo-form textarea {
+ background: var(--bg-surface);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ color: var(--text-primary);
+ border-radius: 10px;
+ padding: 12px;
+ font: inherit;
+}
+
+.demo-form input:focus,
+.demo-form textarea:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+}
+
+.bg-glow {
+ position: fixed;
+ z-index: -1;
+ filter: blur(80px);
+ pointer-events: none;
+}
+
+.bg-glow--one {
+ width: 300px;
+ height: 300px;
+ background: rgba(74, 222, 158, 0.15);
+ top: -80px;
+ right: -100px;
+}
+
+.bg-glow--two {
+ width: 260px;
+ height: 260px;
+ background: rgba(88, 166, 255, 0.14);
+ bottom: 120px;
+ left: -120px;
+}
+
+@media (max-width: 1040px) {
+ .hero-grid,
+ .footer-inner,
+ .split {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-mark {
+ width: 120px;
+ right: 8px;
+ bottom: 8px;
+ }
+
+ .quick-metrics,
+ .cards-grid,
+ .vision-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .section {
+ padding: 68px 0;
+ }
+}
+
+@media (max-width: 1140px) {
+ .menu-toggle {
+ display: inline-flex;
+ }
+
+ .header-nav {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ left: 0;
+ display: none;
+ flex-direction: column;
+ align-items: stretch;
+ background: rgba(13, 17, 23, 0.98);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 14px;
+ padding: 12px;
+ gap: 4px;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
+ white-space: normal;
+ }
+
+ .header-nav.open {
+ display: flex;
+ }
+
+ .header-nav a {
+ padding: 10px 12px;
+ border-radius: 10px;
+ }
+
+ .header-nav a:hover {
+ background: rgba(255, 255, 255, 0.04);
+ }
+
+ .header-nav .cta-link {
+ margin-left: 0;
+ margin-top: 6px;
+ text-align: center;
+ }
+}
+
+@media (max-width: 820px) {
+ .hero {
+ padding-top: 54px;
+ }
+
+ .container {
+ width: min(var(--container-width), calc(100% - 28px));
+ }
+}
|