feat: establish frontend foundation

This commit is contained in:
2026-08-12 23:04:16 +03:00
parent 3331b7f684
commit 1e13adabd3
46 changed files with 2868 additions and 1588 deletions
+1
View File
@@ -0,0 +1 @@
export { App } from "./ui/App";
+81
View File
@@ -0,0 +1,81 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it } from "vitest";
import {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "../../pages/accounts";
import { theme } from "../theme";
import { AppRoutes } from "./AppRoutes";
describe("AppRoutes", () => {
afterEach(cleanup);
it.each([
["/", "Счета"],
["/login", "Вход"],
["/register", "Регистрация"],
["/accounts", "Счета"],
["/accounts/demo", "Счет"],
["/settings", "Настройки"],
["/unknown", "Страница не найдена"],
])("renders %s with its page title", (path, title) => {
render(
<MemoryRouter initialEntries={[path]}>
<MantineProvider theme={theme}>
<AppRoutes />
</MantineProvider>
</MemoryRouter>,
);
expect(
screen.getByRole("heading", { level: 1, name: title }),
).toBeInTheDocument();
});
it("renders only the empty state for accounts by default", () => {
render(
<MemoryRouter initialEntries={["/accounts"]}>
<MantineProvider theme={theme}>
<AppRoutes />
</MantineProvider>
</MemoryRouter>,
);
expect(
screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }),
).toBeInTheDocument();
expect(screen.queryByText("Загрузка")).not.toBeInTheDocument();
expect(screen.queryByText("Что-то пошло не так")).not.toBeInTheDocument();
});
it.each([
["loading", AccountsPageLoading, "status"],
["error", AccountsPageError, "alert"],
["empty", AccountsPageEmpty, undefined],
] as const)("renders only the Accounts %s state", (_name, State, role) => {
render(
<MantineProvider theme={theme}>
<State />
</MantineProvider>,
);
if (role) {
expect(screen.getByRole(role)).toBeInTheDocument();
} else {
expect(
screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }),
).toBeInTheDocument();
}
expect(screen.queryAllByRole("status")).toHaveLength(
role === "status" ? 1 : 0,
);
expect(screen.queryAllByRole("alert")).toHaveLength(
role === "alert" ? 1 : 0,
);
expect(screen.queryAllByText("Пока ничего нет")).toHaveLength(role ? 0 : 1);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { Navigate, Route, Routes } from "react-router";
import { AccountPage } from "../../pages/account";
import { AccountsPage } from "../../pages/accounts";
import { LoginPage } from "../../pages/login";
import { NotFoundPage } from "../../pages/not-found";
import { RegisterPage } from "../../pages/register";
import { SettingsPage } from "../../pages/settings";
import { AppShell } from "../../shared/ui/AppShell";
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<AppShell />}>
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/accounts/:accountId" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
+1
View File
@@ -0,0 +1 @@
export { AppRoutes } from "./AppRoutes";
+55
View File
@@ -0,0 +1,55 @@
:root {
--ft-color-bg-page: #e8ecf1;
--ft-color-bg-app: #f5f7fa;
--ft-color-bg-surface: #ffffff;
--ft-color-bg-muted: #f1f5f9;
--ft-color-text-primary: #1e293b;
--ft-color-text-secondary: #64748b;
--ft-color-border: #e2e8f0;
--ft-color-accent: #2563eb;
--ft-color-danger: #ef4444;
--ft-radius-card: 12px;
--ft-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ft-color-bg-page);
color: var(--ft-color-text-primary);
}
a {
color: inherit;
text-decoration: none;
}
.app-header {
align-items: center;
display: flex;
padding: 0 1rem;
}
.bottom-nav {
display: none;
}
@media (max-width: 47.99375em) {
.bottom-nav {
background: var(--ft-color-bg-surface);
border-top: 1px solid var(--ft-color-border);
bottom: 0;
display: flex;
justify-content: space-around;
left: 0;
padding: 0.75rem;
position: fixed;
right: 0;
z-index: 200;
}
.bottom-nav .active {
color: var(--ft-color-accent);
font-weight: 600;
}
.mantine-AppShell-main {
padding-bottom: 5rem;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { createTheme } from "@mantine/core";
export const theme = createTheme({
primaryColor: "blue",
defaultRadius: "md",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
colors: {
blue: [
"#eff6ff",
"#dbeafe",
"#bfdbfe",
"#93c5fd",
"#60a5fa",
"#3b82f6",
"#2563eb",
"#1d4ed8",
"#1e40af",
"#1e3a8a",
],
},
});
+22
View File
@@ -0,0 +1,22 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router";
import { AppRoutes } from "../routes";
import { theme } from "../theme";
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
});
export function App() {
return (
<MantineProvider theme={theme} forceColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
);
}