From f29fe3a18568a63f82d926263b81ae9d103a74ec Mon Sep 17 00:00:00 2001 From: olegvls Date: Tue, 23 Jun 2026 12:00:03 +0300 Subject: [PATCH 01/13] fix: resolve merge conflict and add linkButton styles --- .../pages/DriversPage/DriversPage.module.css | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css b/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css index 5a506705..73201a74 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); +} From 1c8a1c78c7f6247e3e768c095d09ad8d66b194b2 Mon Sep 17 00:00:00 2001 From: olegvls Date: Tue, 23 Jun 2026 13:51:12 +0300 Subject: [PATCH 02/13] change form add car and change validation in backend --- .../Validators/CreateVehicleDtoValidator.cs | 12 +- apps/Mobile/src/api/authTokenBridge.ts | 15 + apps/Mobile/src/api/client.ts | 149 +-- .../src/components/AppAlert/AppAlert.tsx | 178 ++++ apps/Mobile/src/components/AppAlert/index.ts | 4 + .../src/components/AppAlert/useAppAlert.tsx | 64 ++ apps/Mobile/src/components/Select/Select.tsx | 23 +- apps/Mobile/src/context/AuthContext.tsx | 5 + apps/Mobile/src/queries/vehicleQueries.ts | 5 +- .../CarCardScreen/CarCardScreen.styles.ts | 69 +- .../screens/CarCardScreen/CarCardScreen.tsx | 885 ++++++++++++------ .../CarDetailsScreen/CarDetailsScreen.tsx | 19 +- .../src/screens/HomeScreen/HomeScreen.tsx | 8 +- apps/Mobile/src/utils/apiErrorUtils.ts | 49 + apps/Mobile/src/utils/vehicleFormUtils.ts | 134 +++ apps/Mobile/src/utils/vinUtils.ts | 12 + 16 files changed, 1205 insertions(+), 426 deletions(-) create mode 100644 apps/Mobile/src/api/authTokenBridge.ts create mode 100644 apps/Mobile/src/components/AppAlert/AppAlert.tsx create mode 100644 apps/Mobile/src/components/AppAlert/index.ts create mode 100644 apps/Mobile/src/components/AppAlert/useAppAlert.tsx create mode 100644 apps/Mobile/src/utils/apiErrorUtils.ts create mode 100644 apps/Mobile/src/utils/vehicleFormUtils.ts create mode 100644 apps/Mobile/src/utils/vinUtils.ts diff --git a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs index 55217e6f..0dfa4822 100644 --- a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs +++ b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs @@ -61,12 +61,18 @@ public CreateVehicleDtoValidator() .When(x => x.BoughtAt.HasValue); RuleFor(x => x.EngineCapacity) - .NotEmpty() - .WithMessage("Engine capacity is required.") .GreaterThan(0) .WithMessage("Engine capacity must be greater than 0.") .LessThanOrEqualTo(10000) - .WithMessage("Engine capacity must be less than or equal to 10000."); + .WithMessage("Engine capacity must be less than or equal to 10000.") + .When(x => x.FuelType is not FuelType.Electric and not FuelType.Hydrogen); + + RuleFor(x => x.EngineCapacity) + .GreaterThanOrEqualTo(0) + .WithMessage("Engine capacity cannot be negative.") + .LessThanOrEqualTo(10000) + .WithMessage("Engine capacity must be less than or equal to 10000.") + .When(x => x.FuelType is FuelType.Electric or FuelType.Hydrogen); RuleFor(x => x.Mileage) .NotEmpty() diff --git a/apps/Mobile/src/api/authTokenBridge.ts b/apps/Mobile/src/api/authTokenBridge.ts new file mode 100644 index 00000000..721fd44e --- /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 ff6e92b2..74bd9b06 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 00000000..69a033f1 --- /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/index.ts b/apps/Mobile/src/components/AppAlert/index.ts new file mode 100644 index 00000000..f95596c1 --- /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 { useAppAlert } from "./useAppAlert"; +export type { ShowAppAlertOptions } from "./useAppAlert"; diff --git a/apps/Mobile/src/components/AppAlert/useAppAlert.tsx b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx new file mode 100644 index 00000000..a520fe19 --- /dev/null +++ b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx @@ -0,0 +1,64 @@ +import { useCallback, useState } from "react"; +import { AppAlert, AppAlertButton } from "./AppAlert"; + +export interface ShowAppAlertOptions { + title: string; + message?: string; + buttons?: AppAlertButton[]; +} + +interface AppAlertState extends ShowAppAlertOptions { + visible: boolean; +} + +export function useAppAlert() { + 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 alertModal = alertState ? ( + + ) : null; + + return { + showAlert, + showError, + showSuccess, + hideAlert, + alertModal, + }; +} diff --git a/apps/Mobile/src/components/Select/Select.tsx b/apps/Mobile/src/components/Select/Select.tsx index 37fafc19..8d675710 100644 --- a/apps/Mobile/src/components/Select/Select.tsx +++ b/apps/Mobile/src/components/Select/Select.tsx @@ -84,10 +84,7 @@ export const Select: React.FC = ({ disabled={disabled} > {selectedOption ? selectedOption.label : placeholder} @@ -109,7 +106,10 @@ export const Select: React.FC = ({ activeOpacity={1} onPress={handleModalClose} /> - true}> + true} + > {/* Заголовок */} @@ -171,9 +171,7 @@ export const Select: React.FC = ({ /> ) : ( - - Нічого не знайдено - + Нічого не знайдено )} @@ -240,6 +238,8 @@ const styles = StyleSheet.create({ maxHeight: 600, overflow: "hidden", shadowColor: "#000", + borderWidth: 1, + borderColor: "#21262d", // theme.colors.border.primary shadowOffset: { width: 0, height: 4, @@ -256,7 +256,7 @@ const styles = StyleSheet.create({ alignItems: "center", paddingHorizontal: 20, paddingVertical: 16, - borderBottomWidth: 1, + borderBottomColor: "#30363d", // theme.colors.border.primary }, modalTitle: { @@ -285,6 +285,8 @@ const styles = StyleSheet.create({ }, searchInput: { marginBottom: 0, + borderWidth: 1, + borderColor: "#3bc98e", }, optionsContainer: { flex: 1, @@ -330,6 +332,3 @@ const styles = StyleSheet.create({ marginLeft: 12, }, }); - - - diff --git a/apps/Mobile/src/context/AuthContext.tsx b/apps/Mobile/src/context/AuthContext.tsx index 032a1401..0ab5d8d5 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 901fea99..0e0cdb96 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, }); diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts index d17f853e..1773868c 100644 --- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts +++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts @@ -20,9 +20,44 @@ export const styles = StyleSheet.create({ form: { flex: 1, }, + stepHeaderContainer: { + marginBottom: 16, + }, + stepHeaderTop: { + marginBottom: 10, + }, + stepTitle: { + fontSize: 16, + fontWeight: "700", + marginBottom: 4, + }, + stepSubtitle: { + fontSize: 14, + }, + stepDots: { + flexDirection: "row", + gap: 8, + }, + stepDot: { + flex: 1, + height: 6, + borderRadius: 999, + backgroundColor: "#30363D", + }, + stepDotCompleted: { + backgroundColor: "#2EA043", + }, + stepDotActive: { + backgroundColor: "#F2CC60", + }, + stepCard: { + paddingHorizontal: 16, + paddingVertical: 18, + marginBottom: 12, + }, imageSection: { alignItems: "center", - marginBottom: 32, + marginBottom: 24, }, imageContainer: { width: 200, @@ -59,6 +94,36 @@ export const styles = StyleSheet.create({ input: { // Additional input styles if needed }, + dateInputText: { + paddingVertical: 12, + }, + datePickerModalRoot: { + flex: 1, + justifyContent: "flex-end", + }, + datePickerBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + datePickerSheet: { + backgroundColor: "#161B22", + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingBottom: 24, + }, + datePickerHeader: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#30363D", + }, + datePickerDoneText: { + color: "#4ade9e", + fontWeight: "600", + }, vinDecodeContainer: { flexDirection: "row", gap: 12, @@ -79,7 +144,7 @@ export const styles = StyleSheet.create({ buttonContainer: { flexDirection: "row", justifyContent: "space-between", - marginTop: 20, + marginTop: 8, marginBottom: 40, gap: 12, }, diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx index b90b320e..807578c4 100644 --- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx +++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx @@ -1,13 +1,15 @@ -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 { FormScreen } from "../../components/FormScreen"; import DateTimePicker from "@react-native-community/datetimepicker"; @@ -18,6 +20,7 @@ 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 +36,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 +68,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 +104,21 @@ const WHEEL_DRIVE_API_OPTIONS: SelectOption[] = [ { label: "4WD", value: WheelDriveType.FourWD }, ]; +const TOTAL_STEPS = 6; +const STEP_TITLES = [ + "Базова інформація", + "Ідентифікація авто", + "Історія використання", + "Двигун", + "Трансмісія та привід", + "Фото та завершення", +]; + export default function CarCardScreen({ navigation }: CarCardScreenProps) { const { getAccessToken } = useAuth(); const queryClient = useQueryClient(); const theme = useTheme(); + const { showAlert, showError, showSuccess, alertModal } = useAppAlert(); const [carImage, setCarImage] = useState(null); const [brand, setBrand] = useState(undefined); const [model, setModel] = useState(undefined); @@ -97,12 +126,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 +148,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 +164,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 +179,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 +194,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 +202,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 +292,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 +337,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 +353,8 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { style: "destructive", onPress: () => navigation.navigate("Home"), }, - ] - ); + ], + }); } else { navigation.navigate("Home"); } @@ -309,71 +362,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 +440,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 +471,7 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { queryClient.invalidateQueries({ queryKey: queryKeys.vehicles }); - Alert.alert("Успішно!", "Автомобіль додано в базу", [ + showSuccess("Автомобіль додано в базу", "Успішно!", [ { text: "OK", onPress: () => navigation.navigate("Home"), @@ -419,13 +479,145 @@ 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 ( - - - Додай свій автомобіль - - - Створи картку твого авто - - + + + Додай свій автомобіль + + + Створи картку твого авто + + - - {/* VIN Decode Field */} - {/* */} - - {/* Car Image */} - - - {carImage ? ( - - ) : ( - - 🚗 - Фото авто - - )} - + + + + + Крок {currentStep + 1} з {TOTAL_STEPS} + + + {STEP_TITLES[currentStep]} + + + + {Array.from({ length: TOTAL_STEPS }).map((_, index) => ( + + ))} + - {/* Brand Field */} - - + {isLoadingBrands && ( + + + + )} - )} - - {/* Model Field */} - - + {isLoadingModels && ( + + + + )} - )} - - {/* Year Field */} - + + )} + + {currentStep === 1 && ( + <> + + + + Номерний знак * + + + - {/* License Plate Field */} - - - Номерний знак * - - - + + + VIN номер * + + setVin(normalizeVinInput(text))} + placeholder="17 символів (латиниця, без I, O, Q)" + placeholderTextColor={theme.colors.special.placeholder} + autoCapitalize="characters" + maxLength={17} + /> + - {/* VIN Field */} - - - VIN номер * - - - + + )} + + {currentStep === 2 && ( + <> + + + Дата купівлі + + + + {boughtAt.toLocaleDateString("uk-UA")} + + + - {/* Date bought */} - - - Дата купівлі - - setShowBoughtAtPicker(true)} - > - + + Пробіг (км) * + + + + + )} + + {currentStep === 3 && ( + <> + + {requiresEngineDisplacement(fuelType) && ( + + + Об'єм двигуна (л) * + + + + )} - {/* Fuel Type Field */} - + + )} + + {currentStep === 4 && ( + <> + setDriveType(v as number)} + placeholder="Виберіть тип приводу" + /> + + )} + + {currentStep === 5 && ( + + + {carImage ? ( + + ) : ( + + 🚗 + Додати фото авто + + )} + + + )} + + + {/* Action Buttons */} + + + + {currentStep === 0 ? "Скасувати" : "Назад"} - - - - {/* Transmission Field */} - setDriveType(v as number)} - placeholder="Виберіть тип приводу" - /> - - {/* Action Buttons */} - + {currentStep < TOTAL_STEPS - 1 ? ( - Скасувати + Далі - + ) : ( Зберегти - + )} + + + {Platform.OS === "ios" ? ( + + + + + + + + Готово + + + + { + if (date) setPickerDate(date); + }} + /> + + + + ) : ( + showBoughtAtPicker && ( + { + setShowBoughtAtPicker(false); + if (event.type === "set" && date) { + setBoughtAt(date); + } + }} + /> + ) + )} + {alertModal} ); } diff --git a/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx b/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx index 18b12ae5..738e5f89 100644 --- a/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx +++ b/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx @@ -15,6 +15,7 @@ import { styles } from "./CarDetailsScreen.styles"; import { CarDetailsScreenProps } from "../../navigation/types"; import { useVehicle, useDeleteVehicle } from "../../queries"; import { FuelType, TransmissionType, WheelDriveType } from "../../types/api"; +import { requiresEngineDisplacement } from "../../utils/vehicleFormUtils"; const FUEL_LABELS: Record = { [FuelType.Gasoline]: "Бензин", @@ -248,14 +249,16 @@ export default function CarDetailsScreen({ - - - Об'єм двигуна: - - - {car.engineCapacity / 1000} л - - + {requiresEngineDisplacement(car.fuelType) && ( + + + Об'єм двигуна: + + + {(car.engineCapacity / 1000).toFixed(1)} л + + + )} diff --git a/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx b/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx index d011ca10..5597f1f8 100644 --- a/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx +++ b/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx @@ -23,6 +23,7 @@ import { RefreshStatusBar } from "../../components/RefreshStatusBar"; import { ServiceHistoryContent } from "../ServiceHistoryScreen/ServiceHistoryContent"; import { useTheme } from "../../hooks/useTheme"; import { FuelType, TransmissionType, WheelDriveType } from "../../types/api"; +import { formatEngineDisplay } from "../../utils/vehicleFormUtils"; const FUEL_LABELS: Record = { [FuelType.Gasoline]: "Бензин", @@ -487,8 +488,11 @@ export default function HomeScreen({ navigation }: HomeScreenProps) { Двигун - {(car.engineCapacity / 1000).toFixed(1)} л,{" "} - {car.enginePower} к.с. + {formatEngineDisplay( + car.engineCapacity, + car.enginePower, + car.fuelType as FuelType + )} diff --git a/apps/Mobile/src/utils/apiErrorUtils.ts b/apps/Mobile/src/utils/apiErrorUtils.ts new file mode 100644 index 00000000..824ae977 --- /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 00000000..8cc08fd7 --- /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 00000000..1c494516 --- /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) та цифри"; From 3fdb3cfba9c6de5573fb94a98b92c1a318d89b5b Mon Sep 17 00:00:00 2001 From: olegvls Date: Wed, 1 Jul 2026 13:51:01 +0300 Subject: [PATCH 03/13] fix review --- .../MainHub.Api/Services/ServiceHistoryService.cs | 11 ----------- Services/Main/MainHub.Api/Services/VehicleService.cs | 6 +++--- .../Validators/CreateVehicleDtoValidator.cs | 8 ++------ 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs b/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs index 95fe550a..4efc7c81 100644 --- a/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs +++ b/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs @@ -34,12 +34,6 @@ public interface IServiceHistoryService /// A task that represents the asynchronous operation. Task DeleteAllByVehicleIdAsync(Guid vehicleId, Guid userId); - /// - /// Deletes all service history for a vehicle without ownership validation. - /// Caller must verify that the user owns the vehicle before invoking. - /// - Task DeleteAllByVehicleIdUncheckedAsync(Guid vehicleId); - /// /// Retrieves a service history record by its unique identifier asynchronously. /// @@ -85,11 +79,6 @@ public async Task DeleteAllByVehicleIdAsync(Guid vehicleId, Guid userId) await _repository.DeleteAllByVehicleIdAsync(vehicleId); } - public Task DeleteAllByVehicleIdUncheckedAsync(Guid vehicleId) - { - return _repository.DeleteAllByVehicleIdAsync(vehicleId); - } - public async Task UpdateAsync(Guid vehicleId, Guid serviceHistoryId, CreateServiceHistoryDetailsDto updateServiceHistoryDetailsDto, Guid userId) { await EnsureValidVehicleRequest(userId, vehicleId); diff --git a/Services/Main/MainHub.Api/Services/VehicleService.cs b/Services/Main/MainHub.Api/Services/VehicleService.cs index 39b72c3b..52dd4320 100644 --- a/Services/Main/MainHub.Api/Services/VehicleService.cs +++ b/Services/Main/MainHub.Api/Services/VehicleService.cs @@ -63,12 +63,12 @@ public interface IVehicleService public class VehicleService( IVehicleRepository repository, IUserService userService, - IServiceHistoryRepository serviceHistoryRepository + IServiceHistoryService serviceHistoryService ) : IVehicleService { private readonly IVehicleRepository _repository = repository; private readonly IUserService _userService = userService; - private readonly IServiceHistoryRepository _serviceHistoryRepository = serviceHistoryRepository; + private readonly IServiceHistoryService _serviceHistoryService = serviceHistoryService; public async Task GetVehicleByIdAsync(Guid vehicleId, Guid userId) { @@ -210,7 +210,7 @@ public async Task DeleteAsync(Guid vehicleId, Guid userId) throw new ArgumentException("Vehicle not associated with the user.", nameof(vehicleId)); } - await _serviceHistoryRepository.DeleteAllByVehicleIdAsync(vehicleId); + await _serviceHistoryService.DeleteAllByVehicleIdAsync(vehicleId, userId); await _userService.DetachVehicleAsync(vehicleId, userId); var vehicle = await _repository.GetByIdAsync(vehicleId); diff --git a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs index 3561645d..2a62e47d 100644 --- a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs +++ b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Text.RegularExpressions; using FluentValidation; using MainHub.Api.DTOs; @@ -61,6 +60,8 @@ public CreateVehicleDtoValidator() .When(x => x.BoughtAt.HasValue); RuleFor(x => x.EngineCapacity) + .GreaterThanOrEqualTo(0) + .WithMessage("Engine capacity cannot be negative.") .LessThanOrEqualTo(10000) .WithMessage("Engine capacity must be less than or equal to 10000."); @@ -69,11 +70,6 @@ public CreateVehicleDtoValidator() .WithMessage("Engine capacity must be greater than 0.") .When(x => x.FuelType is not FuelType.Electric and not FuelType.Hydrogen); - RuleFor(x => x.EngineCapacity) - .GreaterThanOrEqualTo(0) - .WithMessage("Engine capacity cannot be negative.") - .When(x => x.FuelType is FuelType.Electric or FuelType.Hydrogen); - RuleFor(x => x.Mileage) .NotEmpty() .WithMessage("Mileage is required.") From 368cfbc0cb6f27bdaecff10b23f1b3af83379225 Mon Sep 17 00:00:00 2001 From: olegvls Date: Wed, 1 Jul 2026 14:59:37 +0300 Subject: [PATCH 04/13] add visits and online registration --- .../MainHub.Api/Services/VehicleService.cs | 7 + apps/AdminWebUi/src/App.tsx | 5 +- .../ActivityLog/ActivityLog.module.css | 233 ++++++++++++ .../components/ActivityLog/ActivityLog.tsx | 120 ++++++ .../src/components/ActivityLog/index.ts | 1 + .../BookingRequestCard.module.css | 152 ++++++++ .../BookingRequestCard/BookingRequestCard.tsx | 74 ++++ .../components/BookingRequestCard/index.ts | 1 + .../BookingRequestDetailModal.module.css | 358 ++++++++++++++++++ .../BookingRequestDetailModal.tsx | 293 ++++++++++++++ .../BookingRequestDetailModal/index.ts | 1 + .../BookingStatusBadge.module.css | 18 + .../BookingStatusBadge/BookingStatusBadge.tsx | 49 +++ .../components/BookingStatusBadge/index.ts | 1 + .../components/DataTable/DataTable.module.css | 9 + .../src/components/DataTable/DataTable.tsx | 8 +- .../StoVehicleActivityLog.tsx | 19 + .../components/StoVehicleActivityLog/index.ts | 1 + .../StoVehicleDetailModal.module.css | 2 +- .../StoVehicleDetailModal.tsx | 3 + .../VisitDetailModal.module.css | 248 ++++++++++++ .../VisitDetailModal/VisitDetailModal.tsx | 161 ++++++++ .../src/components/VisitDetailModal/index.ts | 1 + .../VisitStatusBadge.module.css | 18 + .../VisitStatusBadge/VisitStatusBadge.tsx | 28 ++ .../src/components/VisitStatusBadge/index.ts | 1 + apps/AdminWebUi/src/config/navigation.ts | 14 + .../src/context/BookingRequestContext.tsx | 119 ++++++ .../src/context/StoVehicleContext.tsx | 2 +- .../src/data/mockBookingRequests.ts | 124 ++++++ apps/AdminWebUi/src/data/mockVisits.ts | 209 ++++++++++ .../BookingRequestsPage.module.css | 175 +++++++++ .../BookingRequestsPage.tsx | 185 +++++++++ .../src/pages/BookingRequestsPage/index.ts | 1 + .../pages/VisitsPage/VisitsPage.module.css | 124 ++++++ .../src/pages/VisitsPage/VisitsPage.tsx | 162 ++++++++ apps/AdminWebUi/src/pages/VisitsPage/index.ts | 1 + apps/AdminWebUi/src/routes/AppRouter.tsx | 20 + apps/AdminWebUi/src/types/bookingRequest.ts | 34 ++ apps/AdminWebUi/src/types/stoVehicle.ts | 31 ++ apps/AdminWebUi/src/types/visit.ts | 33 ++ apps/AdminWebUi/src/utils/bookingLabels.ts | 82 ++++ .../src/utils/stoVehicleActivityLog.ts | 214 +++++++++++ apps/AdminWebUi/src/utils/visitActivityLog.ts | 131 +++++++ apps/AdminWebUi/src/utils/visitLabels.ts | 82 ++++ 45 files changed, 3551 insertions(+), 4 deletions(-) create mode 100644 apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css create mode 100644 apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx create mode 100644 apps/AdminWebUi/src/components/ActivityLog/index.ts create mode 100644 apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css create mode 100644 apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx create mode 100644 apps/AdminWebUi/src/components/BookingRequestCard/index.ts create mode 100644 apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css create mode 100644 apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx create mode 100644 apps/AdminWebUi/src/components/BookingRequestDetailModal/index.ts create mode 100644 apps/AdminWebUi/src/components/BookingStatusBadge/BookingStatusBadge.module.css create mode 100644 apps/AdminWebUi/src/components/BookingStatusBadge/BookingStatusBadge.tsx create mode 100644 apps/AdminWebUi/src/components/BookingStatusBadge/index.ts create mode 100644 apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx create mode 100644 apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts create mode 100644 apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css create mode 100644 apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx create mode 100644 apps/AdminWebUi/src/components/VisitDetailModal/index.ts create mode 100644 apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css create mode 100644 apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx create mode 100644 apps/AdminWebUi/src/components/VisitStatusBadge/index.ts create mode 100644 apps/AdminWebUi/src/context/BookingRequestContext.tsx create mode 100644 apps/AdminWebUi/src/data/mockBookingRequests.ts create mode 100644 apps/AdminWebUi/src/data/mockVisits.ts create mode 100644 apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css create mode 100644 apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx create mode 100644 apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts create mode 100644 apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css create mode 100644 apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx create mode 100644 apps/AdminWebUi/src/pages/VisitsPage/index.ts create mode 100644 apps/AdminWebUi/src/types/bookingRequest.ts create mode 100644 apps/AdminWebUi/src/types/visit.ts create mode 100644 apps/AdminWebUi/src/utils/bookingLabels.ts create mode 100644 apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts create mode 100644 apps/AdminWebUi/src/utils/visitActivityLog.ts create mode 100644 apps/AdminWebUi/src/utils/visitLabels.ts diff --git a/Services/Main/MainHub.Api/Services/VehicleService.cs b/Services/Main/MainHub.Api/Services/VehicleService.cs index 52dd4320..90654fe5 100644 --- a/Services/Main/MainHub.Api/Services/VehicleService.cs +++ b/Services/Main/MainHub.Api/Services/VehicleService.cs @@ -334,6 +334,13 @@ IReadOnlyDictionary ownerMap Year = vehicle.Year, PhotoUrl = vehicle.PhotoUrl, OwnerUserId = ownerUserId, + Vin = vehicle.Vin, + FuelType = vehicle.FuelType, + TransmissionType = vehicle.TransmissionType, + WheelDriveType = vehicle.WheelDriveType, + Color = vehicle.Color, + Mileage = vehicle.Mileage, + CreatedAt = vehicle.CreatedAt, }; } } diff --git a/apps/AdminWebUi/src/App.tsx b/apps/AdminWebUi/src/App.tsx index 9630cc9c..57d38541 100644 --- a/apps/AdminWebUi/src/App.tsx +++ b/apps/AdminWebUi/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./auth/AuthContext"; import { StoProvider } from "./context/StoContext"; import { StoVehicleProvider } from "./context/StoVehicleContext"; +import { BookingRequestProvider } from "./context/BookingRequestContext"; import { queryClient } from "./lib/queryClient"; import { AppRouter } from "./routes/AppRouter"; import { persistor, store } from "./store"; @@ -19,7 +20,9 @@ function App() { - + + + diff --git a/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css new file mode 100644 index 00000000..4cd79dee --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css @@ -0,0 +1,233 @@ +.section { + margin: 20px 24px 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + background-color: #1a1c1e; + overflow: hidden; +} + +.toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 16px 20px; + border: none; + background: linear-gradient( + 180deg, + rgba(74, 222, 158, 0.04) 0%, + transparent 100% + ); + color: inherit; + cursor: pointer; + text-align: left; +} + +.toggle:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.toggleMain { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.toggleTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.countBadge { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); + background-color: rgba(74, 222, 158, 0.12); + border: 1px solid rgba(74, 222, 158, 0.24); +} + +.toggleHint { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.chevron { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + transition: + transform 0.3s ease, + color 0.2s ease, + border-color 0.2s ease; +} + +.chevronExpanded { + transform: rotate(180deg); + color: var(--accent-primary); + border-color: rgba(74, 222, 158, 0.24); +} + +.bodyWrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.32s ease; +} + +.bodyWrapperExpanded { + grid-template-rows: 1fr; +} + +.bodyInner { + overflow: hidden; + min-height: 0; + opacity: 0; + transition: opacity 0.24s ease; +} + +.bodyWrapperExpanded .bodyInner { + opacity: 1; +} + +.body { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.scrollArea { + max-height: 360px; + overflow-y: auto; + padding: 12px 16px 16px; +} + +.scrollArea::-webkit-scrollbar { + width: 6px; +} + +.scrollArea::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.12); + border-radius: 3px; +} + +.list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.entry { + display: grid; + grid-template-columns: 20px 1fr; + gap: 12px; + padding: 12px 4px; + position: relative; +} + +.entry:not(:last-child)::after { + content: ""; + position: absolute; + left: 9px; + top: 32px; + bottom: -4px; + width: 2px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 1px; +} + +.marker { + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 4px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04); +} + +.entryMain { + min-width: 0; +} + +.entryHeader { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.entryTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.35; +} + +.entryTime { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; +} + +.entryMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.actorName { + font-size: 12px; + color: var(--text-secondary); +} + +.actorRole { + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--text-tertiary); + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.entryDescription { + margin: 0; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.45; +} + +.empty { + padding: 20px 16px; + font-size: 13px; + color: var(--text-tertiary); + text-align: center; +} diff --git a/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx new file mode 100644 index 00000000..baf77828 --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import type { StoVehicleActivityEntry, StoVehicleActivityType } from "../../types/stoVehicle"; +import { ACTIVITY_ACTOR_ROLE_LABELS } from "../../utils/stoVehicleActivityLog"; +import { formatEventDateTime } from "../../utils/stoVehicleLabels"; +import styles from "./ActivityLog.module.css"; + +const ACTIVITY_DOT_COLORS: Record = { + vehicle_created: "#6e7681", + appointment_scheduled: "#58a6ff", + vehicle_arrived: "#4ade9e", + status_changed: "#a371f7", + mechanic_assigned: "#d29922", + approval_requested: "#ffa657", + approval_sent_to_client: "#58a6ff", + approval_approved: "#4ade9e", + approval_rejected: "#f85149", +}; + +interface ActivityLogProps { + entries: StoVehicleActivityEntry[]; + hint?: string; +} + +function ChevronIcon({ expanded }: { expanded: boolean }) { + return ( + + ); +} + +export function ActivityLog({ + entries, + hint = "Повний життєвий цикл: статуси, погодження, призначення механіка", +}: ActivityLogProps) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + +
+
+
+ {entries.length === 0 ? ( +

Записів у журналі поки немає.

+ ) : ( +
+
    + {entries.map((entry) => ( +
  • +
    + +
    +
    +
    +

    {entry.title}

    + +
    +
    + {entry.actorName} + + {ACTIVITY_ACTOR_ROLE_LABELS[entry.actorRole]} + +
    + {entry.description ? ( +

    {entry.description}

    + ) : null} +
    +
  • + ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/AdminWebUi/src/components/ActivityLog/index.ts b/apps/AdminWebUi/src/components/ActivityLog/index.ts new file mode 100644 index 00000000..bf5f12d2 --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/index.ts @@ -0,0 +1 @@ +export { ActivityLog } from "./ActivityLog"; diff --git a/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css new file mode 100644 index 00000000..535fe375 --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css @@ -0,0 +1,152 @@ +.card { + background-color: #1a1c1e; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.15s ease; +} + +.card:hover { + border-color: rgba(74, 222, 158, 0.28); + transform: translateY(-1px); +} + +.cardPending { + border-color: rgba(255, 166, 87, 0.2); +} + +.cardHeader { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + flex-shrink: 0; + background-color: rgba(74, 222, 158, 0.1); + border-radius: var(--radius-md); + color: var(--accent-primary); +} + +.icon svg { + width: 22px; + height: 22px; +} + +.headerContent { + flex: 1; + min-width: 0; +} + +.titleRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.vehicleTitle { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; +} + +.plateBadge { + flex-shrink: 0; + padding: 3px 8px; + background-color: #1a4038; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); +} + +.clientName { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.reason { + margin: 0; + font-size: 13px; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.metaGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.metaItem { + padding: 8px 10px; + border-radius: var(--radius-sm); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.metaLabel { + display: block; + margin-bottom: 2px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.metaValue { + font-size: 12px; + color: var(--text-primary); + line-height: 1.35; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.createdAt { + font-size: 11px; + color: var(--text-tertiary); +} + +.footerMeta { + display: flex; + align-items: center; + gap: 10px; +} + +.attachments { + font-size: 11px; + color: var(--text-secondary); +} + +@media (max-width: 700px) { + .metaGrid { + grid-template-columns: 1fr; + } +} diff --git a/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx new file mode 100644 index 00000000..05490f94 --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx @@ -0,0 +1,74 @@ +import type { BookingRequest } from "../../types/bookingRequest"; +import { + formatPreferredSlot, + PARTS_OPTION_LABELS, + VISIT_REASON_LABELS, +} from "../../utils/bookingLabels"; +import { formatEventDateTime, formatStoVehicleTitle } from "../../utils/stoVehicleLabels"; +import { BookingStatusBadge } from "../BookingStatusBadge"; +import styles from "./BookingRequestCard.module.css"; + +interface BookingRequestCardProps { + request: BookingRequest; + onOpen: (request: BookingRequest) => void; +} + +export function BookingRequestCard({ request, onOpen }: BookingRequestCardProps) { + const title = formatStoVehicleTitle(request.brand, request.model, request.year); + const preferredSlot = formatPreferredSlot(request.preferredDate, request.preferredTimeSlot); + + return ( +
onOpen(request)} + > +
+ +
+
+

{title}

+ {request.licensePlate} +
+

{request.clientName}

+
+
+ +

+ {VISIT_REASON_LABELS[request.visitReason]} — {request.problemDescription} +

+ +
+
+ Зручно водію + {preferredSlot} +
+
+ Запчастини + {PARTS_OPTION_LABELS[request.partsOption]} +
+
+ +
+ +
+ {request.attachmentsCount ? ( + 📎 {request.attachmentsCount} + ) : null} + {formatEventDateTime(request.createdAt)} +
+
+
+ ); +} diff --git a/apps/AdminWebUi/src/components/BookingRequestCard/index.ts b/apps/AdminWebUi/src/components/BookingRequestCard/index.ts new file mode 100644 index 00000000..ced5ac6f --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestCard/index.ts @@ -0,0 +1 @@ +export { BookingRequestCard } from "./BookingRequestCard"; diff --git a/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css new file mode 100644 index 00000000..6f54eabe --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css @@ -0,0 +1,358 @@ +.modalContent { + display: flex; + flex-direction: column; + min-height: 480px; +} + +.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); +} + +.sourcePill { + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: #58a6ff; + background-color: rgba(88, 166, 255, 0.14); + border: 1px solid rgba(88, 166, 255, 0.28); +} + +.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: 120px 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; +} + +.problemSection { + padding: 20px 24px; + border-bottom: 1px solid var(--border-secondary); +} + +.sectionTitle { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.problemCard { + padding: 14px 16px; + border-radius: var(--radius-md); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.reasonBadge { + display: inline-block; + margin-bottom: 8px; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); + background-color: rgba(74, 222, 158, 0.12); + border: 1px solid rgba(74, 222, 158, 0.24); +} + +.problemText { + margin: 0; + font-size: 14px; + color: var(--text-primary); + line-height: 1.5; +} + +.schedulingSection { + padding: 20px 24px 24px; +} + +.schedulingGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +.preferenceCard, +.confirmCard { + padding: 16px; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.preferenceCard { + background-color: rgba(88, 166, 255, 0.06); + border-color: rgba(88, 166, 255, 0.18); +} + +.confirmCard { + background-color: rgba(74, 222, 158, 0.05); + border-color: rgba(74, 222, 158, 0.18); +} + +.cardTitle { + margin: 0 0 10px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.preferenceCard .cardTitle { + color: #58a6ff; +} + +.confirmCard .cardTitle { + color: var(--accent-primary); +} + +.preferenceValue { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; +} + +.preferenceHint { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + +.datetimeInput { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-primary); + background-color: var(--bg-tertiary); + color: var(--text-primary); + font-size: 14px; + font-family: inherit; +} + +.datetimeInput:focus { + outline: none; + border-color: var(--border-focus); +} + +.fieldError { + margin: 6px 0 0; + font-size: 12px; + color: var(--accent-error); +} + +.noteTextarea { + width: 100%; + margin-top: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-primary); + background-color: var(--bg-tertiary); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + resize: vertical; +} + +.noteTextarea:focus { + outline: none; + border-color: var(--border-focus); +} + +.attachmentsNote { + margin: 0 0 16px; + padding: 10px 12px; + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--text-secondary); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding-top: 4px; +} + +.rejectForm { + display: flex; + flex-direction: column; + gap: 10px; + flex-basis: 100%; + padding: 12px; + border-radius: var(--radius-sm); + background-color: rgba(248, 81, 73, 0.06); + border: 1px solid rgba(248, 81, 73, 0.18); +} + +.rejectHint { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.resolvedBanner { + padding: 14px 16px; + border-radius: var(--radius-md); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.resolvedTitle { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.resolvedText { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.45; +} + +.successMessage { + margin: 0; + flex-basis: 100%; + font-size: 13px; + color: var(--accent-primary); +} + +@media (max-width: 700px) { + .summaryGrid, + .schedulingGrid { + grid-template-columns: 1fr; + } + + .summaryRow { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx new file mode 100644 index 00000000..e8ad5ab4 --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "../Button"; +import { Modal } from "../Modal"; +import { BookingStatusBadge } from "../BookingStatusBadge"; +import type { BookingRequest } from "../../types/bookingRequest"; +import { + formatBookingDateTime, + formatPreferredSlot, + fromDatetimeLocalValue, + PARTS_OPTION_LABELS, + suggestedAppointmentIso, + toDatetimeLocalValue, + VISIT_REASON_LABELS, +} from "../../utils/bookingLabels"; +import { formatEventDateTime, formatStoVehicleTitle } from "../../utils/stoVehicleLabels"; +import styles from "./BookingRequestDetailModal.module.css"; + +interface BookingRequestDetailModalProps { + request: BookingRequest | null; + isOpen: boolean; + onClose: () => void; + onConfirm: (requestId: string, appointmentAt: string, managerNote?: string) => void; + onReject: (requestId: string, reason: string) => void; +} + +export function BookingRequestDetailModal({ + request, + isOpen, + onClose, + onConfirm, + onReject, +}: BookingRequestDetailModalProps) { + const [appointmentLocal, setAppointmentLocal] = useState(""); + const [managerNote, setManagerNote] = useState(""); + const [appointmentError, setAppointmentError] = useState(null); + const [showRejectForm, setShowRejectForm] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + if (!request) return; + const suggested = suggestedAppointmentIso(request.preferredDate, request.preferredTimeSlot); + setAppointmentLocal(toDatetimeLocalValue(suggested)); + setManagerNote(""); + setAppointmentError(null); + setShowRejectForm(false); + setRejectReason(""); + setSuccessMessage(null); + }, [request?.id]); + + if (!request) return null; + + const vehicleTitle = formatStoVehicleTitle(request.brand, request.model, request.year); + const preferredSlot = formatPreferredSlot(request.preferredDate, request.preferredTimeSlot); + const isPending = request.status === "pending"; + + const handleConfirm = () => { + if (!appointmentLocal) { + setAppointmentError("Вкажіть дату та час запису на СТО"); + return; + } + + setAppointmentError(null); + onConfirm(request.id, fromDatetimeLocalValue(appointmentLocal), managerNote); + setSuccessMessage("Запис підтверджено. Авто додано до черги зі статусом «Записано»."); + setShowRejectForm(false); + }; + + const handleReject = () => { + if (!rejectReason.trim()) return; + onReject(request.id, rejectReason.trim()); + setShowRejectForm(false); + }; + + return ( + +
+
+
+ +
+

Онлайн-запис

+
+ {request.licensePlate} + + Додаток водія +
+
+
+ +
+ +
+
+

Автомобіль

+
+
+
Марка / модель
+
{vehicleTitle}
+
+
+
Номер
+
{request.licensePlate}
+
+ {request.vin ? ( +
+
VIN
+
{request.vin}
+
+ ) : null} +
+
Запчастини
+
{PARTS_OPTION_LABELS[request.partsOption]}
+
+
+
+ +
+

Водій

+
+
+
Ім'я
+
{request.clientName}
+
+
+
Телефон
+
{request.clientPhone ?? "—"}
+
+
+
Надіслано
+
{formatEventDateTime(request.createdAt)}
+
+ {request.attachmentsCount ? ( +
+
Медіа
+
{request.attachmentsCount} фото/відео від водія
+
+ ) : null} +
+
+
+ +
+

Опис проблеми

+
+ + {VISIT_REASON_LABELS[request.visitReason]} + +

{request.problemDescription}

+
+
+ +
+

Планування візиту

+ + {request.attachmentsCount ? ( +

+ Водій додав {request.attachmentsCount} медіафайл(ів) до запиту. Перегляд вкладень + буде доступний після підключення API. +

+ ) : null} + +
+
+

Зручний час для водія

+

{preferredSlot}

+

+ Врахуйте побажання клієнта при підтвердженні запису +

+
+ +
+

Реальний час на СТО

+ {isPending ? ( + <> + { + setAppointmentLocal(event.target.value); + setAppointmentError(null); + }} + /> + {appointmentError ? ( +

{appointmentError}

+ ) : null} + + + + +
+ + + + + + + diff --git a/apps/landing/script.js b/apps/landing/script.js new file mode 100644 index 00000000..581bcdb9 --- /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 00000000..a7b288f8 --- /dev/null +++ b/apps/landing/styles.css @@ -0,0 +1,996 @@ +: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 { + border-left: 1px solid rgba(255, 255, 255, 0.12); + margin-left: 11px; +} + +.timeline-item { + display: grid; + grid-template-columns: 28px 1fr; + gap: 14px; + padding: 0 0 28px; +} + +.timeline-item:last-child { + padding-bottom: 0; +} + +.timeline .dot { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid rgba(74, 222, 158, 0.35); + background: rgba(26, 64, 56, 0.55); + margin-left: -12px; + margin-top: 4px; +} + +.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)); + } +} From d91b4bf019639cb9faef3405b1f8ca7785630f17 Mon Sep 17 00:00:00 2001 From: olegvls Date: Fri, 3 Jul 2026 11:57:35 +0300 Subject: [PATCH 12/13] landing2 --- apps/landing/index.html | 190 +++++++++++++++++++++++++++++----------- apps/landing/styles.css | 46 +++++++--- 2 files changed, 174 insertions(+), 62 deletions(-) diff --git a/apps/landing/index.html b/apps/landing/index.html index bdc98f63..c3d6743e 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -139,10 +139,12 @@

Робочі кабінети майстрів

-

Довіра як базова валюта

+

Цифровий паспорт автомобіля

- Прозорий обмін даними між водієм і СТО підвищує довіру один до - одного та змінює стандарт сервісного досвіду на ринку. + Після кожного візиту до партнерського СТО в профілі авто + автоматично з’являється верифікований запис обслуговування. Усі + підтверджені візити збираються в єдиний цифровий паспорт — цілісну + історію, яка зростає разом із автомобілем.

@@ -205,6 +207,7 @@

Застосунок для водіїв

в одному зручному середовищі.

    +
  • Цифровий паспорт автомобіля
  • Верифікована історія авто
  • Realtime статуси ремонту
  • Пошук партнерських сервісів
  • @@ -426,12 +429,12 @@

    Клієнти з новою цифровою звичкою

    -

    Прямий діалог з клієнтом

    +

    Прямий діалог і рекламні push

    - Швидкі сценарії комунікації та погодження в застосунку - пришвидшують прийняття рішень. Push-сповіщення інформують клієнта - про ключові етапи ремонту та важливі події — без зайвих дзвінків - і втрат часу для вашої команди. + Сервісні push-сповіщення інформують клієнта про ключові етапи + ремонту без зайвих дзвінків. Окремо передбачені рекламні push для + ваших прив’язаних клієнтів: безкоштовно повідомляйте про акції та + послуги, допродавуючи сервіс без витрат на зовнішню рекламу.

    @@ -443,6 +446,17 @@

    Допродаж послуг через застосунок

    адміністративного навантаження.

    +
    +

    Ваша клієнтська база під захистом

    +

    + Клієнти, яких ви додаєте в систему, не стають доступними іншим + учасникам екосистеми як чужа аудиторія. Модель підписок на СТО + одразу прив’язує доданого клієнта до вашої бази. Водій може, як і + в реальному житті, підписатися на інші сервіси або відписатися від + неактуальних — конкуренція залишається здоровою, а ваші зусилля з + залучення захищені. +

    +

    Напрям додаткового доходу

    @@ -454,10 +468,24 @@

    Напрям додаткового доходу

    -

    Цифрова перевага для невеликих майстерень

    +

    Підписка замість дорогої власної розробки

    +

    + Подібні системи дійсно корисні для бізнесу СТО, але їх розробка + коштує дуже дорого. Навіть після запуску цифровий продукт і надалі + вимагає великих витрат лише на підтримку. Rookit дає доступ до + такої екосистеми за мінімальну підписку — без інвестицій у сотні + тисяч доларів на власну розробку та обслуговування. +

    +
    +
    +

    Мережевий ефект кластера

    - Навіть невеликі майстерні отримують інструменти рівня мережевих - СТО - без витрат на дорогу індивідуальну розробку власної системи. + Кожен новий водій, який приходить у застосунок не через окремий + сервіс, а через сарафанне радіо про екосистему, стає потенційним + клієнтом для всіх учасників кластера — без ваших витрат на + залучення. Мережевий ефект працює в обидва боки: кожен новий клієнт + і кожен новий партнерський СТО підсилюють цінність платформи для + всіх інших.

@@ -493,12 +521,14 @@

1) Проблема уваги

-

2) Сила звички

+

2) Історія, що подорожує з клієнтом

- Водій користується екосистемою не тільки під час поломки: перевіряє - історію, шукає сервіси, взаємодіє з авто щоденно. Кожен вхід - підсилює звичку і підвищує ймовірність повернення саме до - партнерського СТО. + Водій не прив’язаний до одного сервісу: за потреби він може + звертатися до будь-якого партнера екосистеми. Переїхавши в інше + місто, клієнт зберігає всю накопичену історію, яка й надалі + поповнюється в інших партнерських СТО. Автомобіль «живе» в + застосунку — і вихід із системи означає втрату цілісності цієї + історії, тому мотивація залишатися в екосистемі значно вища.

@@ -512,11 +542,13 @@

3) Кластерний ефект партнерів

-

Один застосунок - вища конверсія

+

Один застосунок — звичка і лояльність

- Один зрозумілий продукт простіше запам’ятати, легше рекомендувати і - простіше регулярно використовувати. Це дає стабільніше залучення, - ніж розпорошення на окремі брендові застосунки. + Один зрозумілий продукт простіше запам’ятати і регулярно + використовувати. Водій заходить у застосунок не лише під час + поломки — перевіряє історію, шукає сервіси, взаємодіє з авто + щоденно. Кожен вхід підсилює звичку і підвищує ймовірність + повернення до партнерського СТО.

@@ -532,14 +564,16 @@

Новий водій у екосистемі — ваш потенційн
-

Екосистема, яка росте разом із вашим бізнесом

+

Самопідсилювана екосистема

- Платформа задумана не лише як інструмент для щоденної роботи, а й як - середовище, яке з часом може приносити сервісу додатковий дохід. Ми - орієнтуємось на розвиток застосунку для водіїв, тестування нових - сценаріїв разом із партнерами та формування функціоналу, який - допомагає окуповувати вкладення в цифрову систему. Це напрям руху - продукту, а не перелік готових можливостей. + Rookit — не набір розрізнених інструментів, а взаємопов’язана система, + де кожен елемент підсилює інший. З кожною взаємодією в застосунку, + CRM або кабінеті майстра зростає загальна цінність платформи для всіх + учасників. Це дає перевагу над вузькими нішевими рішеннями: ми + запускаємо маховик, де кожен компонент працює на залучення, + нагадування та продаж ваших послуг клієнтам. Паралельно ми рухаємось + у напрямку функціоналу, який допомагає сервісу окуповувати вкладення + в цифрову систему.

@@ -553,18 +587,25 @@

Регулярний корисний досвід, що формує дов
-
-
-

Верифікована історія обслуговування

+
+ +
+
+

Цифровий паспорт і верифікована історія

- Партнери (СТО) підтверджують виконані роботи, і записи зберігаються - у профілі авто як цифровий актив для майбутнього продажу. + Партнерське СТО підтверджує виконані роботи, і запис автоматично + потрапляє в цифровий паспорт авто. Водій також може самостійно + додавати власні записи — наприклад, про ремонт вдома. Але лише + історія, верифікована сервісом, має статус офіційної — саме вона + формує рейтинг автомобіля, корисний при продажі.

-
-
+
+ +
+

Realtime статуси ремонту

Клієнт бачить процес ремонту в реальному часі через статуси в @@ -573,8 +614,10 @@

Realtime статуси ремонту

-
-
+
+ +
+

Застосунок, куди хочеться повертатися

Об’ємний корисний функціонал підтримує регулярне використання @@ -583,12 +626,16 @@

Застосунок, куди хочеться повертатися

-
-
-

Вища цінність авто при продажі

+
+ +
+
+

Рейтинг авто на основі офіційної історії

- Наявність підтвердженої історії підвищує довіру до власника і - потенційно збільшує ринкову ціну автомобіля. + Різниця між власними записами та верифікованою історією мотивує + водіїв регулярніше обслуговуватися у партнерських СТО. Накопичена + офіційна історія формує рейтинг автомобіля, що підвищує довіру + покупця та потенційну вартість авто на вторинному ринку.

@@ -653,10 +700,12 @@

Стійка лояльність клієнтів

-

Нове позиціонування для СТО

+

Самопідсилювана цінність платформи

- Партнерські сервіси отримують відчутну перевагу над традиційними - форматами ведення сервісу завдяки прозорості та технологічності. + Кожна взаємодія в екосистемі — візит до СТО, оновлення статусу, + перегляд історії — підсилює загальну цінність Rookit для всіх + учасників. Система працює як маховик: більше користувачів — сильніша + платформа — більше можливостей для кожного партнера.

@@ -721,13 +770,13 @@

Поширені питання власників СТО

- Чому ви не робите окремий застосунок під бренд кожного СТО? + Чому ви не робите окремий застосунок під мій бренд СТО?

Водій не буде тримати в телефоні десяток застосунків від різних сервісів — він залишить один або не завантажить жодного. Спільна екосистема вирішує це: один застосунок, де «живе» авто клієнта, куди він повертається регулярно. Ваш сервіс присутній у цьому - середовищі, а не в окремому застосунку, який клієнт швидше видалить. + середовищі, а не в окремому застосунку, про який клієнт забуде після візиту. Власний брендовий застосунок для більшості СТО — це високі витрати на розробку і просування без гарантії регулярного використання.

@@ -763,6 +812,35 @@

Поширені питання власників СТО

проходження авто через сервіс.

+
+ Що таке цифровий паспорт автомобіля? +

+ Це єдиний цифровий профіль авто, куди автоматично потрапляють + верифіковані записи після кожного візиту до партнерського СТО. + Історія збирається в одному місці, зростає з кожним обслуговуванням + і стає цінним активом для водія — зокрема при продажі автомобіля. +

+
+
+ Чи бачать інші СТО моїх клієнтів? +

+ Ні. Клієнти, яких ви додаєте в систему, прив’язуються до вашої бази + через модель підписок на СТО і не стають доступними іншим учасникам + як чужа аудиторія. Водій може самостійно підписатися на інші + партнерські сервіси або відписатися від неактуальних — як у реальному + житті, зі збереженням здорової конкуренції. +

+
+
+ Чи може клієнт обслуговуватися в іншому місті? +

+ Так. Водій не обмежений одним сервісом і може звертатися до будь-якого + партнера екосистеми. Уся накопичена історія зберігається і продовжує + поповнюватися в нових партнерських СТО. Це підвищує цінність + залишатися в системі: автомобіль «живе» в застосунку, і вихід із + нього означає втрату цілісності накопиченої історії. +

+
Як верифікована історія допомагає утримувати клієнтів?

@@ -777,11 +855,11 @@

Поширені питання власників СТО

Чи підходить Rookit невеликій майстерні, а не лише великим мережам?

- Так. Навіть невелика майстерня отримує цифрові інструменти рівня - великих мережевих СТО — CRM, кабінети майстрів і присутність у - спільному застосунку для водіїв — без інвестицій у власну розробку - системи. Це дає технологічну перевагу над сервісами, які досі - працюють у традиційному форматі. + Так. Власна розробка подібної системи коштує дуже дорого, а після + запуску потребує ще й постійних витрат на підтримку. Rookit відкриває + доступ до готової екосистеми за мінімальну підписку — без інвестицій + у сотні тисяч доларів. Тому і невелика майстерня, і більший сервіс + отримують однаково потужний цифровий інструментарій.

@@ -815,6 +893,16 @@

Поширені питання власників СТО

передбачуваніша робота з клієнтами.

+
+ Чим відрізняється офіційна історія від записів, доданих водієм? +

+ Водій може безкоштовно додавати власні записи — наприклад, про ремонт, + виконаний самостійно. Але лише історія, верифікована партнерським + СТО, отримує статус офіційної. Саме офіційні записи впливають на + рейтинг автомобіля, що мотивує водіїв регулярніше обслуговуватися + у партнерських сервісах і підвищує цінність авто при продажі. +

+
Які нові можливості монетизації відкриває платформа?

diff --git a/apps/landing/styles.css b/apps/landing/styles.css index a7b288f8..0a5b571b 100644 --- a/apps/landing/styles.css +++ b/apps/landing/styles.css @@ -759,29 +759,53 @@ h3 { } .timeline { - border-left: 1px solid rgba(255, 255, 255, 0.12); - margin-left: 11px; + display: flex; + flex-direction: column; + gap: 0; } .timeline-item { display: grid; - grid-template-columns: 28px 1fr; - gap: 14px; - padding: 0 0 28px; + 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 { - width: 22px; - height: 22px; + position: relative; + z-index: 1; + display: block; + width: 12px; + height: 12px; border-radius: 50%; - border: 2px solid rgba(74, 222, 158, 0.35); - background: rgba(26, 64, 56, 0.55); - margin-left: -12px; - margin-top: 4px; + background: var(--accent-primary); + box-shadow: 0 0 0 5px #14181d; } .timeline h3 { From 66b62a0a02e0eae497adfefd0662fb974154ef31 Mon Sep 17 00:00:00 2001 From: olegvls Date: Fri, 3 Jul 2026 12:04:58 +0300 Subject: [PATCH 13/13] landing3 --- apps/landing/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/landing/index.html b/apps/landing/index.html index c3d6743e..1ac8b685 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -89,7 +89,7 @@

Створюємо нову культуру сервісного обслу