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 @@
VITE_API_BASE_URL=http://localhost:3000
+25
View File
@@ -0,0 +1,25 @@
# Frontend
## Назначение
React/Vite-клиент WealthLedger. Функциональные изменения сначала планируются в `../specs/openspec`; контракт API задает backend OpenAPI, а не ручные DTO.
## Проверка
```bash
npm run lint
npm run typecheck
npm run test:run
npm run build
npm run check:no-todo
```
`npm run openapi:generate` получает документ работающего backend по `${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json`; фактическая генерация выполняется только в задаче 5.1. `build` выполняет `tsc -b`.
## Границы
- Текущий baseline использует FSD, React Router, i18n, Mantine, React Query и typed API scaffold.
- Зависимости FSD идут `app -> pages -> features -> entities -> shared`; слайсы импортируются только через публичный `index.ts`.
- HTTP идет через типизированный клиент, сгенерированный из OpenAPI; UI-строки - только через i18n. Каждый маршрут имеет loading, error и empty состояния.
- Соблюдать strict TypeScript: `import type` для типов, без `enum` и `namespace`.
- Не добавлять прямые вызовы T-Bank и не переносить Todo-код из `../references/`.
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"useIgnoreFile": true
},
"files": {
"includes": [
"**",
"!dist",
"!src/shared/api/generated",
"!coverage",
"!node_modules"
]
},
"linter": {
"rules": {
"preset": "recommended"
}
}
}
-22
View File
@@ -1,22 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+1947 -1048
View File
File diff suppressed because it is too large Load Diff
+43 -32
View File
@@ -1,34 +1,45 @@
{
"name": "wealth-ledger-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^8.2.0"
}
"name": "wealth-ledger-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc -b --noEmit",
"build": "tsc -b && vite build",
"lint": "biome check .",
"test": "vitest",
"test:run": "vitest run",
"check:no-todo": "! rg -i todo src --glob '*.{ts,tsx}'",
"openapi:generate": "openapi-typescript \"${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json\" -o src/shared/api/generated/schema.d.ts",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^8.3.14",
"@tanstack/react-query": "^5.90.3",
"i18next": "^26.0.2",
"openapi-fetch": "^0.17.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^16.3.5",
"react-router": "^7.9.6"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@biomejs/biome": "2.5.8",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^6.0.4",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "~5.9.3",
"vite": "^8.2.0",
"vitest": "^4.0.16",
"jsdom": "^27.4.0",
"openapi-typescript": "^7.10.1"
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg">
<svg xmlns="http://www.w3.org/2000/svg"><title>Icon sprite</title>
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
-122
View File
@@ -1,122 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
export default App
+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>
);
}
-111
View File
@@ -1,111 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+17 -9
View File
@@ -1,10 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@mantine/core/styles.css";
import "./app/styles.css";
import "./shared/i18n";
import { App } from "./app";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+6
View File
@@ -0,0 +1,6 @@
export {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoading,
} from "./ui/AccountPage";
+42
View File
@@ -0,0 +1,42 @@
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StateView } from "../../../shared/ui/StateView";
export function AccountPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("account.title")}</Title>
<AccountPageEmpty />
</Stack>
);
}
export function AccountPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountPageError() {
const { t } = useTranslation();
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
/>
);
}
export function AccountPageEmpty() {
const { t } = useTranslation();
return (
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
);
}
+6
View File
@@ -0,0 +1,6 @@
export {
AccountsPage,
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "./ui/AccountsPage";
+42
View File
@@ -0,0 +1,42 @@
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StateView } from "../../../shared/ui/StateView";
export function AccountsPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsPageEmpty />
</Stack>
);
}
export function AccountsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountsPageError() {
const { t } = useTranslation();
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
/>
);
}
export function AccountsPageEmpty() {
const { t } = useTranslation();
return (
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
);
}
+6
View File
@@ -0,0 +1,6 @@
export {
LoginPage,
LoginPageEmpty,
LoginPageError,
LoginPageLoading,
} from "./ui/LoginPage";
+42
View File
@@ -0,0 +1,42 @@
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StateView } from "../../../shared/ui/StateView";
export function LoginPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("auth.login")}</Title>
<LoginPageEmpty />
</Stack>
);
}
export function LoginPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function LoginPageError() {
const { t } = useTranslation();
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
/>
);
}
export function LoginPageEmpty() {
const { t } = useTranslation();
return (
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
);
}
+6
View File
@@ -0,0 +1,6 @@
export {
NotFoundPage,
NotFoundPageEmpty,
NotFoundPageError,
NotFoundPageLoading,
} from "./ui/NotFoundPage";
+51
View File
@@ -0,0 +1,51 @@
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Button, StateView } from "../../../shared/ui/StateView";
export function NotFoundPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("notFound.title")}</Title>
<NotFoundPageEmpty />
</Stack>
);
}
export function NotFoundPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function NotFoundPageError() {
const { t } = useTranslation();
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
/>
);
}
export function NotFoundPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("states.emptyTitle")}
text={t("states.emptyText")}
action={
<Button component={Link} to="/accounts">
{t("notFound.action")}
</Button>
}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
export {
RegisterPage,
RegisterPageEmpty,
RegisterPageError,
RegisterPageLoading,
} from "./ui/RegisterPage";
+42
View File
@@ -0,0 +1,42 @@
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StateView } from "../../../shared/ui/StateView";
export function RegisterPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("auth.register")}</Title>
<RegisterPageEmpty />
</Stack>
);
}
export function RegisterPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function RegisterPageError() {
const { t } = useTranslation();
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
/>
);
}
export function RegisterPageEmpty() {
const { t } = useTranslation();
return (
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
);
}
+6
View File
@@ -0,0 +1,6 @@
export {
SettingsPage,
SettingsPageEmpty,
SettingsPageError,
SettingsPageLoading,
} from "./ui/SettingsPage";
+42
View File
@@ -0,0 +1,42 @@
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StateView } from "../../../shared/ui/StateView";
export function SettingsPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("settings.title")}</Title>
<SettingsPageEmpty />
</Stack>
);
}
export function SettingsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function SettingsPageError() {
const { t } = useTranslation();
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
/>
);
}
export function SettingsPageEmpty() {
const { t } = useTranslation();
return (
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
);
}
+6
View File
@@ -0,0 +1,6 @@
import createClient from "openapi-fetch";
import { apiBaseUrl } from "./config";
import type { paths } from "./generated/schema";
export const apiClient = createClient<paths>({ baseUrl: apiBaseUrl });
+2
View File
@@ -0,0 +1,2 @@
export const apiBaseUrl =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
+72
View File
@@ -0,0 +1,72 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/v1": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["AppController_getHello"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
SafeErrorResponseDto: {
/** @example 500 */
statusCode: number;
/** @example Internal server error */
message: string;
/** @example /api/v1 */
path: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
AppController_getHello: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": string;
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SafeErrorResponseDto"];
};
};
};
};
}
+2
View File
@@ -0,0 +1,2 @@
export { apiClient } from "./client";
export { apiBaseUrl } from "./config";
+13
View File
@@ -0,0 +1,13 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { ru } from "./locales/ru";
void i18n.use(initReactI18next).init({
resources: { ru },
lng: "ru",
fallbackLng: "ru",
interpolation: { escapeValue: false },
});
export { i18n };
+19
View File
@@ -0,0 +1,19 @@
export const ru = {
translation: {
appName: "WealthLedger",
navigation: { accounts: "Счета", settings: "Настройки" },
auth: { login: "Вход", register: "Регистрация" },
accounts: { title: "Счета" },
account: { title: "Счет" },
settings: { title: "Настройки" },
notFound: { title: "Страница не найдена", action: "К счетам" },
states: {
loadingTitle: "Загрузка",
loadingText: "Пожалуйста, подождите.",
errorTitle: "Что-то пошло не так",
errorText: "Попробуйте обновить страницу позже.",
emptyTitle: "Пока ничего нет",
emptyText: "Данные появятся здесь.",
},
},
} as const;
+49
View File
@@ -0,0 +1,49 @@
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
export function AppShell() {
const { t } = useTranslation();
const { pathname } = useLocation();
const items = [
{ to: "/accounts", label: t("navigation.accounts") },
{ to: "/settings", label: t("navigation.settings") },
];
return (
<MantineAppShell
header={{ height: 60 }}
navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }}
padding="md"
>
<MantineAppShell.Header className="app-header">
<Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="sm">
{items.map((item) => (
<NavLink
component={RouterNavLink}
key={item.to}
to={item.to}
label={item.label}
active={pathname.startsWith(item.to)}
/>
))}
</MantineAppShell.Navbar>
<MantineAppShell.Main>
<Outlet />
</MantineAppShell.Main>
<nav className="bottom-nav" aria-label={t("appName")}>
{items.map((item) => (
<RouterNavLink
className={pathname.startsWith(item.to) ? "active" : ""}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
))}
</nav>
</MantineAppShell>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { theme } from "../../app/theme";
import { StateView } from "./StateView";
describe("StateView", () => {
afterEach(cleanup);
it("renders an accessible loading state with a loader", () => {
render(
<MantineProvider theme={theme}>
<StateView loading text="Пожалуйста, подождите." title="Загрузка" />
</MantineProvider>,
);
const status = screen.getByRole("status");
expect(status).toHaveTextContent("Загрузка");
expect(screen.getByLabelText("Загрузка")).toBeInTheDocument();
});
it("renders an accessible error state", () => {
render(
<MantineProvider theme={theme}>
<StateView
error
text="Попробуйте обновить страницу позже."
title="Что-то пошло не так"
/>
,
</MantineProvider>,
);
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core";
import type { ReactNode } from "react";
type StateViewProps = {
title: string;
text: string;
action?: ReactNode;
loading?: boolean;
error?: boolean;
};
export function StateView({
title,
text,
action,
loading = false,
error = false,
}: StateViewProps) {
const role = loading ? "status" : error ? "alert" : undefined;
return (
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
<Stack align="flex-start" gap="sm">
{loading ? <Loader aria-label={title} size="sm" /> : null}
<Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text>
{action}
</Stack>
</Paper>
);
}
export { Button };
+16
View File
@@ -0,0 +1,16 @@
import "@testing-library/jest-dom/vitest";
import "../shared/i18n";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}),
});
+22 -24
View File
@@ -1,28 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
]
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+5 -5
View File
@@ -1,7 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json"},
{ "path": "./tsconfig.node.json"}
]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19 -21
View File
@@ -1,25 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"vite.config.ts"
]
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+5 -8
View File
@@ -1,11 +1,8 @@
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
babel({ presets: [reactCompilerPreset()] })
],
})
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});
+10
View File
@@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
},
});