feat: establish frontend foundation
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
Generated
+1947
-1048
File diff suppressed because it is too large
Load Diff
+43
-32
@@ -1,34 +1,45 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"lint": "eslint .",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"lint": "biome check .",
|
||||||
},
|
"test": "vitest",
|
||||||
"dependencies": {
|
"test:run": "vitest run",
|
||||||
"react": "^19.2.8",
|
"check:no-todo": "! rg -i todo src --glob '*.{ts,tsx}'",
|
||||||
"react-dom": "^19.2.8"
|
"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"
|
||||||
"devDependencies": {
|
},
|
||||||
"@babel/core": "^7.29.7",
|
"dependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@mantine/core": "^8.3.14",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@types/babel__core": "^7.20.5",
|
"i18next": "^26.0.2",
|
||||||
"@types/node": "^24.13.3",
|
"openapi-fetch": "^0.17.0",
|
||||||
"@types/react": "^19.2.17",
|
"react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"react-dom": "^19.2.8",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"react-i18next": "^16.3.5",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"react-router": "^7.9.6"
|
||||||
"eslint": "^10.8.0",
|
},
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"devDependencies": {
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"@babel/core": "^7.29.7",
|
||||||
"globals": "^17.7.0",
|
"@biomejs/biome": "2.5.8",
|
||||||
"typescript": "~6.0.2",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"@types/babel__core": "^7.20.5",
|
||||||
"vite": "^8.2.0"
|
"@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
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
@@ -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">
|
<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>
|
<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>
|
<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
@@ -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
@@ -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
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { App } from "./ui/App";
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AppRoutes } from "./AppRoutes";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -1,10 +1,18 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "@mantine/core/styles.css";
|
||||||
import App from './App.tsx'
|
import "./app/styles.css";
|
||||||
|
import "./shared/i18n";
|
||||||
|
import { App } from "./app";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
const root = document.getElementById("root");
|
||||||
<StrictMode>
|
|
||||||
<App />
|
if (!root) {
|
||||||
</StrictMode>,
|
throw new Error("Root element not found");
|
||||||
)
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
AccountPage,
|
||||||
|
AccountPageEmpty,
|
||||||
|
AccountPageError,
|
||||||
|
AccountPageLoading,
|
||||||
|
} from "./ui/AccountPage";
|
||||||
@@ -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")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
AccountsPage,
|
||||||
|
AccountsPageEmpty,
|
||||||
|
AccountsPageError,
|
||||||
|
AccountsPageLoading,
|
||||||
|
} from "./ui/AccountsPage";
|
||||||
@@ -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")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
LoginPage,
|
||||||
|
LoginPageEmpty,
|
||||||
|
LoginPageError,
|
||||||
|
LoginPageLoading,
|
||||||
|
} from "./ui/LoginPage";
|
||||||
@@ -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")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
NotFoundPage,
|
||||||
|
NotFoundPageEmpty,
|
||||||
|
NotFoundPageError,
|
||||||
|
NotFoundPageLoading,
|
||||||
|
} from "./ui/NotFoundPage";
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
RegisterPage,
|
||||||
|
RegisterPageEmpty,
|
||||||
|
RegisterPageError,
|
||||||
|
RegisterPageLoading,
|
||||||
|
} from "./ui/RegisterPage";
|
||||||
@@ -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")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
SettingsPage,
|
||||||
|
SettingsPageEmpty,
|
||||||
|
SettingsPageError,
|
||||||
|
SettingsPageLoading,
|
||||||
|
} from "./ui/SettingsPage";
|
||||||
@@ -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")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const apiBaseUrl =
|
||||||
|
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
|
||||||
Vendored
+72
@@ -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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { apiClient } from "./client";
|
||||||
|
export { apiBaseUrl } from "./config";
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("Что-то пошло не так");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -1,28 +1,26 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"allowArbitraryExtensions": true,
|
"allowArbitraryExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src"]
|
||||||
"src"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json"},
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json"}
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-21
@@ -1,25 +1,23 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["vite.config.ts"]
|
||||||
"vite.config.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-8
@@ -1,11 +1,8 @@
|
|||||||
import { defineConfig } from 'vite'
|
import babel from "@rolldown/plugin-babel";
|
||||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||||
import babel from '@rolldown/plugin-babel'
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
|
||||||
react(),
|
});
|
||||||
babel({ presets: [reactCompilerPreset()] })
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user