Compare commits
106
Commits
5b69003516
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b3e35ca8 | ||
|
|
41f0ae7469 | ||
|
|
2b94a16ae5 | ||
|
|
e2062ce837 | ||
|
|
d910d624d6 | ||
|
|
cc45352243 | ||
|
|
ed0b5388ab | ||
|
|
134c951178 | ||
|
|
2fd7d59e65 | ||
|
|
668f2ef70e | ||
|
|
cadba8d075 | ||
|
|
2db39f25d9 | ||
|
|
2d070ce2b5 | ||
|
|
5077bd4f0b | ||
|
|
e2d143df46 | ||
|
|
ad93725a84 | ||
|
|
8ce1e8e444 | ||
|
|
aaee9591d7 | ||
|
|
9619dc9791 | ||
|
|
71fbe7774d | ||
|
|
870b7e6327 | ||
|
|
70a2c335fd | ||
|
|
7242240b21 | ||
|
|
71126c91f0 | ||
|
|
6792f5b70a | ||
|
|
32e8fe9402 | ||
|
|
5796d2d789 | ||
|
|
a56edf2b37 | ||
|
|
1d4990491f | ||
|
|
431559be0f | ||
|
|
0e4bbec2a7 | ||
|
|
c24704affa | ||
|
|
58b4e12633 | ||
|
|
3075081df1 | ||
|
|
1a92f023c1 | ||
|
|
df86b8450f | ||
|
|
b5ceb77208 | ||
|
|
b009101b7b | ||
|
|
7e6fd95cfa | ||
|
|
2cd6b1e72d | ||
|
|
3af9df0ae2 | ||
|
|
7b6f34c776 | ||
|
|
ed811e88fa | ||
|
|
eefdde29f8 | ||
|
|
5134106696 | ||
|
|
ebe92684a7 | ||
|
|
6f1226b668 | ||
|
|
d409a297e5 | ||
|
|
ed63adad94 | ||
|
|
bc28973d1a | ||
|
|
20618fb033 | ||
|
|
612ab62509 | ||
|
|
f295dea6a6 | ||
|
|
f3725c202b | ||
|
|
edc7097bd7 | ||
|
|
233680448f | ||
|
|
e38ecd87d6 | ||
|
|
e5f8bbe33c | ||
|
|
f7f53ff8e4 | ||
|
|
df05c8c7db | ||
|
|
e67c47b102 | ||
|
|
43b0f5574a | ||
|
|
c7d8d2c8f9 | ||
|
|
9d900b331a | ||
|
|
b357743c10 | ||
|
|
0d747cc93e | ||
|
|
c315f80b1b | ||
|
|
617edfd489 | ||
|
|
9ad3245efe | ||
|
|
cac4351bc4 | ||
|
|
17f5bae69b | ||
|
|
c8821200ce | ||
|
|
cec9c6348a | ||
|
|
a84411df39 | ||
|
|
3c226f8c29 | ||
|
|
20b2cdfd43 | ||
|
|
dcd1e3c278 | ||
|
|
432cca299f | ||
|
|
54deae57b3 | ||
|
|
74f8a72ed5 | ||
|
|
fcb938b5a2 | ||
|
|
f873db7b02 | ||
|
|
0ea8d7b62d | ||
|
|
c25f6bd85b | ||
|
|
5b30bb802a | ||
|
|
c096fc70ef | ||
|
|
69997075e5 | ||
|
|
6a89b08ee5 | ||
|
|
2844b22dba | ||
|
|
ff67a768ad | ||
|
|
10baefda79 | ||
|
|
f5f4447d60 | ||
|
|
0bc8bc36b7 | ||
|
|
f9f0f6921e | ||
|
|
7c28a423dd | ||
|
|
b404c1802a | ||
|
|
32a77860c8 | ||
|
|
0865b35f93 | ||
|
|
7e976c330c | ||
|
|
320ea5922a | ||
|
|
64fe902192 | ||
|
|
fb3a5a6eaa | ||
|
|
94adbafa34 | ||
|
|
a7537da344 | ||
|
|
a4046dbe87 | ||
|
|
ca0ad4f824 |
+9
-7
@@ -34,9 +34,10 @@ contract; frontend не читает его checkout для генерации
|
||||
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
||||
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
||||
|
||||
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
|
||||
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
|
||||
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
|
||||
Локальный FSD profile: `app → pages → features → entities → shared`.
|
||||
Слой `widgets` не используется: композиция экрана и компонент для одной page
|
||||
остаются у page. Form, mutation или click handler сами по себе не
|
||||
требуют feature. Два callers не делают domain helper generic shared.
|
||||
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
||||
|
||||
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
||||
@@ -69,10 +70,11 @@ Route-aware AppShell и его navigation styles принадлежат `app/ui`
|
||||
`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
|
||||
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
|
||||
|
||||
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
|
||||
`packages/icons`; package не зависит от UI, domain или motion.
|
||||
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
|
||||
Add/Edit/Delete; restore/category SVG остаются локальными.
|
||||
Все SVG, их геометрия и анимации принадлежат private `packages/icons`.
|
||||
Приложение импортирует компоненты из `@wealthledger/icons` и подключает
|
||||
`@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов,
|
||||
единственная задача которых — рисовать иконку. Domain mapping категории в
|
||||
параметры `OperationIcon` остаётся в `entities/bank-card/model`.
|
||||
|
||||
## Примеры
|
||||
|
||||
|
||||
+20
-1
@@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
|
||||
shape/status/route сохраняет
|
||||
публичный контракт предоставленного snapshot.
|
||||
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
|
||||
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
|
||||
`openapi:check` запускает native `--check` того же CLI и падает при устаревших
|
||||
generated types, не меняя tracked files.
|
||||
|
||||
## Auth и invalidation
|
||||
|
||||
@@ -39,6 +40,15 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
|
||||
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
||||
Не добавляй собственный refresh/retry loop в caller.
|
||||
|
||||
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
|
||||
(например, деплой backend) не доказывают конец session: она сохраняется,
|
||||
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
|
||||
запускает refresh. Retry/backoff внутри refresh не добавляется.
|
||||
|
||||
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
||||
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
||||
token stores не добавляются.
|
||||
|
||||
Protected queries используют existing session-aware boundary; logout очищает
|
||||
session-scoped cache. Access token хранится только в памяти, refresh передаётся
|
||||
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
|
||||
@@ -50,6 +60,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
|
||||
от обычного фонового refetch. Даже повторное сохранение того же token начинает
|
||||
новую generation; поздний ответ прежней generation не попадает в новый кэш.
|
||||
|
||||
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
|
||||
без HTTP request registry, URL-wide latest-read counters и access epochs.
|
||||
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
|
||||
data/metadata, без подмены query status и отдельного transport state.
|
||||
Status синхронизации содержит только состояние работы. Resources читаются
|
||||
через `sync=observe` во время работы и после завершения, без hash comparison.
|
||||
Согласованность income entries/groups обеспечивает backend; frontend не
|
||||
дублирует её runtime validator.
|
||||
|
||||
Mutations используют существующий generated client и query-key owner;
|
||||
после успеха обновляй/invalidируй только связанные данные. Optimistic update
|
||||
нужен конкретному UX и обязан иметь rollback/error behavior.
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||
на согласование до зависимой работы.
|
||||
|
||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
|
||||
OpenSpec lifecycle начинай после явного решения Change Owner.
|
||||
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
|
||||
не используй системный Node.
|
||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||
|
||||
## Change scope
|
||||
@@ -57,21 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
|
||||
|
||||
## Проверки и завершение
|
||||
|
||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||
чтения и `git diff --check`; application build ради текста не нужен.
|
||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||
| Когда | Что проверить |
|
||||
| --- | --- |
|
||||
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||
| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. |
|
||||
|
||||
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||
проверку при изменении значимых входов (включая config, dependencies и
|
||||
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. При связанной issue добавь её номер в конец
|
||||
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
|
||||
не управляет этим repo.
|
||||
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
|
||||
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. Исключения release и правила ссылки — в
|
||||
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
|
||||
используется. `main` обновляется только fast-forward из `develop`, поэтому
|
||||
`origin/main` всегда предок `origin/develop`:
|
||||
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
|
||||
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
|
||||
Перед релизом прочитай местный README, раздел «Релизы».
|
||||
|
||||
@@ -19,7 +19,9 @@ StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
|
||||
Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions.
|
||||
Общие Mantine/theme/i18n/Dates providers уже есть в
|
||||
[preview](../../.storybook/preview.tsx); не создавай второй theme source.
|
||||
Stories не достигают application API, auth, VM и query providers даже транзитивно;
|
||||
Stories не достигают application API, auth и query providers даже транзитивно.
|
||||
Presentation VM с form-context/UI-only state допустима; data VM остаётся за
|
||||
этой границей, которую проверяет полный reachable graph;
|
||||
границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога.
|
||||
|
||||
`play` через `storybook/test` нужен, когда interaction иллюстрирует собственный
|
||||
|
||||
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
|
||||
|
||||
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
|
||||
[client test](../../src/shared/api/client.test.ts),
|
||||
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx).
|
||||
[resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
|
||||
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
|
||||
|
||||
## Команды
|
||||
|
||||
@@ -17,6 +17,41 @@
|
||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||
|
||||
Тип значений формы выводится из её schema, когда input/output совпадают.
|
||||
Общие props определяются у одного owner; DTO и presentation model сохраняют
|
||||
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
|
||||
|
||||
## Читаемость: ESLint и review
|
||||
|
||||
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||
JSX siblings и вокруг многострочных declarations/control-flow/return, вынос
|
||||
функций и условных выражений из JSX props, отдельные Zod schema files,
|
||||
отсутствие SVG в приложении, optional trailing parameters и многострочные
|
||||
сложные union types. Generated artifacts не форматируй вручную.
|
||||
URL, строковые/template/regex literals и комментарии исключены из `max-len`;
|
||||
это исключение для содержимого, а не повод собирать длинные expressions.
|
||||
Однострочный JSX не требует обрамляющих скобок; многострочный — требует.
|
||||
`unicorn/no-null` отключён: API DTO, React refs и Mantine используют `null`.
|
||||
Сохраняй различие `null`/`undefined` из контракта вместо массовых suppressions.
|
||||
CSS и Markdown также держи в 80 колонках там, где это не ломает данные/ссылки.
|
||||
|
||||
Review проверяет то, чего синтаксис не доказывает:
|
||||
|
||||
Обоснованное config exception сопровождается причиной и явным ожиданием в
|
||||
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
|
||||
настраивать профиль без случайного ослабления остальных правил.
|
||||
|
||||
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||
- Имена derived values описывают значение, например `projectedAmount`.
|
||||
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||
и многострочное преобразование — отдельная функция с guard и `if/else`.
|
||||
- Union из object shapes оформляй несколькими строками; nullable scalar
|
||||
`null | string` остаётся компактным. Для повторяемого shape используй named type.
|
||||
- Последний необязательный аргумент — `accountId?: string`. Required аргумент
|
||||
перед следующим required параметром может сохранять `T | undefined`.
|
||||
- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view);
|
||||
перенос не меняет null/zero, validation, recovery, closures и hook ordering.
|
||||
|
||||
## Hooks и derived state
|
||||
|
||||
Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен
|
||||
|
||||
+51
-13
@@ -11,13 +11,17 @@ Mantine component и общую тему. Для выпадающего выбо
|
||||
ограничения JSDOM решай в test setup, сохраняя production UI.
|
||||
Семантический HTML для структуры страницы остаётся уместным.
|
||||
|
||||
Icons ищи у существующего owner (например,
|
||||
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx));
|
||||
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
|
||||
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
|
||||
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
|
||||
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
|
||||
components живут у feature/entity/page, даже при повторном использовании.
|
||||
Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
|
||||
lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
|
||||
иконки из model сущности. Каждый SVG хранится цельным декларативным
|
||||
компонентом: не разбивай path-строки и не собирай геометрию в runtime.
|
||||
Русские category names из API — данные для нормализованного сопоставления,
|
||||
не ключи реестра иконок; неизвестные/переименованные категории имеют fallback.
|
||||
Известные названия банков принадлежат enum `BankName` в bank-card model;
|
||||
свободный ввод пользовательского названия остаётся строкой.
|
||||
SVG декоративны по умолчанию; доступное имя action
|
||||
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
|
||||
и доступное имя. Приложение и Storybook подключают CSS пакета.
|
||||
|
||||
Wrapper нужен для проектной ответственности, а не ради нового имени
|
||||
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
||||
@@ -51,10 +55,40 @@ retry callbacks, domain grouping и derived presentation data. View получа
|
||||
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
|
||||
| `Component.module.css` | Локальные стили |
|
||||
|
||||
Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection.
|
||||
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed
|
||||
image fallback) и formatter одного переданного значения. Группировка/доли
|
||||
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
|
||||
View получает готовые данные и именованные действия. Container вызывает VM;
|
||||
VM владеет запросами, формой, состоянием, обработчиками и derived values.
|
||||
Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
|
||||
вызывает container; presentation hook не должен скрывать fetching во view.
|
||||
Presentation VM для form-context/UI-only state может использоваться автономно
|
||||
в каталоге: её граф зависимостей не достигает application data/API.
|
||||
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
||||
|
||||
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
|
||||
type imports, error helpers, router components и form context разрешены.
|
||||
Транзитивный fetching через local presentation hook проверяется review;
|
||||
Storybook дополнительно проверяет reachable graph. Новое исключение должно
|
||||
сохранять presentation-only responsibility.
|
||||
|
||||
JSX props получают ссылки на функции, включая `onClick`, render props,
|
||||
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||
сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`.
|
||||
Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные
|
||||
до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot.
|
||||
|
||||
Внутри компонента нет именованных helpers. Чистые функции форматирования и
|
||||
адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain
|
||||
преобразование остаётся в model своего owner; shared не импортирует entities/pages.
|
||||
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
|
||||
|
||||
VM собирает связные предметные hooks, когда внутри смешиваются независимые
|
||||
сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
|
||||
её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
|
||||
простое подключение одного controller остаётся в VM формы.
|
||||
Не группируй несвязанные функции только по префиксу `on*` и не создавай
|
||||
passthrough hook над одним вызовом ради формального дробления.
|
||||
|
||||
Render-helper замени самостоятельным presentation component у того же owner.
|
||||
|
||||
Отдельный state/section с самостоятельной responsibility выноси в
|
||||
`components/` своего component. Небольшой tightly-coupled renderer оставляй
|
||||
@@ -82,11 +116,15 @@ image fallback) и formatter одного переданного значени
|
||||
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
||||
|
||||
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
||||
лежит рядом со своим view, тестом и story; общий `BankForms.module.css`
|
||||
остаётся в `bank-card/ui`.
|
||||
лежит рядом со своим view, тестом и story. Формы используют Mantine
|
||||
`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
|
||||
|
||||
## Формы
|
||||
|
||||
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
|
||||
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
|
||||
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
|
||||
|
||||
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
|
||||
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
|
||||
Во время submit блокируй все действия формы. Zod errors показывай у поля;
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
!public/**
|
||||
!docker/
|
||||
!docker/**
|
||||
!scripts/
|
||||
!scripts/minimatch-compat/
|
||||
!scripts/minimatch-compat/**
|
||||
!packages/
|
||||
!packages/tokens/
|
||||
!packages/tokens/package.json
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Неверное поведение
|
||||
---
|
||||
## Проблема и шаги воспроизведения
|
||||
|
||||
## Факт / ожидание
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Окружение / ссылки (если применимо)
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Engineering Task
|
||||
about: CI, тесты, документация, процесс и прочее
|
||||
---
|
||||
## Контекст и результат
|
||||
|
||||
## Scope / связанные Issues (если есть)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Новое наблюдаемое поведение
|
||||
---
|
||||
## Зачем и какой результат
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / связанные Issues (если есть)
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Refactoring / Technical Debt
|
||||
about: Улучшение без нового поведения
|
||||
---
|
||||
## Проблема и результат
|
||||
|
||||
## Сохраняемые инварианты / границы
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / ссылки (если есть)
|
||||
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
## Linked Issue
|
||||
Refs
|
||||
|
||||
## Changes
|
||||
|
||||
## Verification
|
||||
- Команда / результат / ограничения:
|
||||
|
||||
## Breaking Changes (если есть)
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Frontend verify
|
||||
|
||||
# Metadata-only: refreshes only the `commits` context after a PR title edit.
|
||||
# Same workflow name keeps the required context; `security` and `verify`
|
||||
# statuses of the head revision stay untouched (no skipped overwrite).
|
||||
on:
|
||||
pull_request:
|
||||
types: [edited]
|
||||
|
||||
concurrency:
|
||||
group: frontend-pr-title-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
commits:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci --ignore-scripts
|
||||
- name: Check PR title and commits
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
|
||||
@@ -34,7 +34,11 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||
else
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
fi
|
||||
|
||||
security:
|
||||
needs: commits
|
||||
@@ -62,7 +66,7 @@ jobs:
|
||||
- name: Verify pre-commit secret blocking and redaction
|
||||
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
|
||||
- name: Audit runtime and development dependencies
|
||||
run: npm audit --include=dev --audit-level=low
|
||||
run: npm audit --include=dev --audit-level=moderate
|
||||
|
||||
verify:
|
||||
needs: [commits, security]
|
||||
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
|
||||
npx lint-staged
|
||||
npm run deps
|
||||
if [ ! -x node_modules/.bin/lint-staged ]; then
|
||||
echo "lint-staged is not installed; run npm ci" >&2
|
||||
exit 1
|
||||
fi
|
||||
node_modules/.bin/lint-staged
|
||||
|
||||
@@ -5,11 +5,16 @@ module.exports = {
|
||||
forbidden: [
|
||||
...base.forbidden,
|
||||
{
|
||||
from: { path: String.raw`\.stories\.tsx$|^\.storybook/preview\.tsx$` },
|
||||
from: { path: "[.]stories[.]tsx$|^[.]storybook/preview[.]tsx$" },
|
||||
name: "stories-must-not-reach-application-data",
|
||||
severity: "error",
|
||||
to: {
|
||||
path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
|
||||
path: [
|
||||
"^src/(shared/api|features/auth|app/routes|app/ui)/",
|
||||
"^src/(entities|features)/[^/]+/api/",
|
||||
"^src/pages/[^/]+/ui/.*Page[.]tsx$",
|
||||
"node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
|
||||
],
|
||||
reachable: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/charts/styles.css";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import "@wealthledger/icons/styles.css";
|
||||
import "@wealthledger/ui/styles.css";
|
||||
import "dayjs/locale/ru";
|
||||
|
||||
import "../src/shared/i18n";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
|
||||
import "../src/shared/i18n";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { DatesProvider } from "@mantine/dates";
|
||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||
(main#28; после merge — актуальная версия `develop`).
|
||||
|
||||
| Решение / сигнал | Читать |
|
||||
| --- | --- |
|
||||
|
||||
+227
@@ -1,5 +1,232 @@
|
||||
# Changelog
|
||||
|
||||
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.4.0...v2.0.0) (2026-11-11)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* support RUB only (WealthLedger/main[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#168](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/168))
|
||||
|
||||
### Features
|
||||
|
||||
* **account:** add performance tab vs Moscow Exchange index (WealthLedger/main[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([#167](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/167)) ([e2062ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2062ce8372d196e00e4dc4e75e072d40b56db21))
|
||||
* clarify grouped coupon payouts ([#162](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/162)) ([#165](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/165)) ([cc45352](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cc45352243ebf4f34295415de2356a2af163f27a))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* polish accounts portfolio summary ([#159](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/159)) ([134c951](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/134c9511789f2f9372d6a9186910067a79c26ad0))
|
||||
* unify history and upcoming events labels ([#161](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/161)) ([#163](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/163)) ([ed0b538](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed0b5388ab148ebf58048beda61022bbee0ab3aa))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* support RUB only (WealthLedger/main[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#168](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/168)) ([2b94a16](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2b94a16ae5c68c3e2134fe68652bdf5b14564783))
|
||||
|
||||
### Tests
|
||||
|
||||
* remove timing races from screen-sync and route transition tests ([#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([#166](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/166)) ([d910d62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d910d624d6583ae0a6ef66133eb7a2bb8eea3e60))
|
||||
|
||||
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
|
||||
|
||||
### Features
|
||||
|
||||
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
|
||||
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
|
||||
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
|
||||
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
|
||||
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
|
||||
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
|
||||
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
|
||||
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
|
||||
|
||||
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
|
||||
|
||||
### Features
|
||||
|
||||
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
|
||||
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
|
||||
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
|
||||
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
|
||||
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
|
||||
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
|
||||
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
|
||||
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
|
||||
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
|
||||
|
||||
### Tests
|
||||
|
||||
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
|
||||
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
|
||||
|
||||
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* keep session when refresh fails on network error or 5xx (WealthLedger/frontend[#97](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/97)) ([3af9df0](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3af9df0ae2f0fb314c276504ef7d50a7472de680))
|
||||
* show snapshot time in browser time zone ([#101](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/101)) ([b009101](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b009101b7b954e8462672a9da859267ebb71edc4))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([2cd6b1e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2cd6b1e72d97117b0c37465aac3d989b0469c1c3))
|
||||
* bump test stack and fix npm audit findings ([#105](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/105)) ([7e6fd95](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e6fd95cfaf7d288242fb46ec7091f561e6f3de5))
|
||||
* **deps:** update non-major dependencies [#9](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/9) ([b5ceb77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b5ceb77208e35a0e389258f3d3c4b85852980042))
|
||||
* resolve dev dependency audit findings (WealthLedger/frontend[#103](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/103)) ([7b6f34c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7b6f34c776fa3517c93b5c3468e3b277aaa6273f))
|
||||
|
||||
## [1.2.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.1.0...v1.2.0) (2026-10-18)
|
||||
|
||||
### Features
|
||||
|
||||
* unify income payment cards and remove comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/33)) ([#74](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/74)) ([43b0f55](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/43b0f5574a79e26de0abae35de16d8aa8700f718))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align password validation with backend ASCII policy (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#89](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/89)) ([ebe9268](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ebe92684a74b4e2617b04ef00d9406b8a0f674ea))
|
||||
* **auth,bank:** guard switch-auth nav and surface banking retry ([e38ecd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e38ecd87d6f69968bb56e6035ceeb6cfda208a3f)), closes [#56](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/56) [#57](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/57)
|
||||
* **bank:** break BankFeedback's shared/api edge for stories ([#80](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/80)) ([2336804](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/233680448f97dc3ec57d7665ec14c68ac92410e5))
|
||||
* deliver screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#85](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/85)) ([ed63ada](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed63adad94f5d0e20f7d0d1446a235f953d10ec2))
|
||||
* **events:** seven-date default period and picker from today (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/63)) ([612ab62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/612ab6250979bf8d9b57f4feb6e1476ed3d759e4))
|
||||
* respect query eligibility in screen sync (WealthLedger/frontend[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([f295dea](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f295dea6a6a33c597c753b52aff629d5382c963a))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* derive payment-system options from schema tuple ([#58](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/58)) ([#75](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/75)) ([e67c47b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e67c47b102329877eb5d9c9d8bb4ea7c0bc70883))
|
||||
|
||||
### Build
|
||||
|
||||
* use native openapi-typescript --check ([#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#77](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/77)) ([df05c8c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/df05c8c7dbb5d6816fb95d54ca43cd0d83eecd48))
|
||||
|
||||
### CI
|
||||
|
||||
* keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend[#62](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/62)) ([#86](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/86)) ([d409a29](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d409a297e5f9ae5658d4efa958bda9a012a859a1))
|
||||
* recheck PR title on edited without the full pipeline (WealthLedger/frontend[#64](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/64)) ([bc28973](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bc28973d1ad5e15bfbc2e5b73b00ba63449933db))
|
||||
|
||||
### Tests
|
||||
|
||||
* await client settlement in late-response cases (WealthLedger/frontend[#61](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/61)) ([#81](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/81)) ([f3725c2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f3725c202bd8cef4ba711085df82a921a2da6b7d))
|
||||
* cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/59)) ([#90](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/90)) ([5134106](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/513410669680480a1f68f5720744cc85e43c8286))
|
||||
* cover null events and income envelopes on account pages ([#72](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/72)) ([#79](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/79)) ([e5f8bbe](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e5f8bbe33cd438f432281818c5276abe9a425646))
|
||||
* cover null resource envelopes for account pages ([#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#71](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/71)) ([#78](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/78)) ([f7f53ff](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f7f53ff8e4de3c1d06fac6063974ff9667145ed6))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* adopt linear git model in frontend docs ([#91](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/91)) ([eefdde2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/eefdde29f8da269581e90bd797078e10a39e472f))
|
||||
|
||||
## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12)
|
||||
|
||||
### Features
|
||||
|
||||
* add stock screener ([a84411d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a84411df39dc131290e9f5b5d9313fed17cc629a))
|
||||
* align investment client with hourly sync ([432cca2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/432cca299f3a9a9e45b2697527369ee95f2d0adc))
|
||||
* render hourly investment states ([3c226f8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3c226f8c29efceccb75af0d6424639a757534f39))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align frontend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([c315f80](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c315f80b1b07ae0888366512ec157b8e29c81d62))
|
||||
* preserve latest stock screener result ([cec9c63](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cec9c6348a389f16f272dac5533aebc9d12ceae8))
|
||||
* show bank summary totals ([20b2cdf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/20b2cdfd432f8b01e447f68657cf5c685a297f1e))
|
||||
|
||||
### Documentation
|
||||
|
||||
* clarify frontend verification policy ([cac4351](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cac4351bc4abf2cec7c5b6611a74d87bf4ec2f70))
|
||||
* pin frontend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([0d747cc](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0d747cc93ee08c337cf300c0367612708f45dfb9))
|
||||
|
||||
### CI
|
||||
|
||||
* skip checks on PR metadata edits ([c882120](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c8821200ce699dab1ce65360d1968d5877a2e78b))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* align frontend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([617edfd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/617edfd489a6a84bb33b57386eca4eb54661f4f0))
|
||||
|
||||
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* drop investment stateVersion/progress/resources
|
||||
from the client contract and use data/metadata readiness instead of
|
||||
rewritten query status. Cross-tab auth may require re-login.
|
||||
|
||||
### Features
|
||||
|
||||
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
|
||||
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
|
||||
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
|
||||
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
|
||||
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
|
||||
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
|
||||
|
||||
### Performance
|
||||
|
||||
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
|
||||
|
||||
### Refactoring
|
||||
|
||||
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
|
||||
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
|
||||
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
|
||||
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
|
||||
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
|
||||
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
|
||||
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
|
||||
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
|
||||
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
|
||||
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
|
||||
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
|
||||
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
|
||||
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
|
||||
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
|
||||
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
|
||||
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
|
||||
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
|
||||
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
|
||||
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
|
||||
|
||||
### CI
|
||||
|
||||
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
|
||||
|
||||
### Tests
|
||||
|
||||
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
|
||||
|
||||
### Styles
|
||||
|
||||
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
|
||||
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
|
||||
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
|
||||
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
|
||||
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
|
||||
|
||||
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
|
||||
|
||||
### Features
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
FROM node:24.21.0-bookworm-slim AS dependencies
|
||||
FROM node:24.22.1-bookworm-slim AS dependencies
|
||||
WORKDIR /app
|
||||
ENV HUSKY=0
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/tokens/package.json ./packages/tokens/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/icons/package.json ./packages/icons/
|
||||
COPY scripts/minimatch-compat ./scripts/minimatch-compat
|
||||
RUN npm ci
|
||||
|
||||
FROM dependencies AS development
|
||||
@@ -19,7 +20,7 @@ COPY . .
|
||||
ENV VITE_API_BASE_URL=""
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.28.0-alpine AS production
|
||||
FROM nginx:1.31.8-alpine AS production
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
LABEL org.opencontainers.image.version=$VERSION \
|
||||
|
||||
@@ -19,8 +19,10 @@ npm run dev
|
||||
|
||||
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
|
||||
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
|
||||
staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
|
||||
Находка или отсутствие Gitleaks блокирует commit.
|
||||
staged diff с полной редакцией секретов, затем запускает установленный
|
||||
`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и
|
||||
не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей.
|
||||
Находка или отсутствие Gitleaks либо lint-staged блокирует commit.
|
||||
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
|
||||
|
||||
## Контейнерный image
|
||||
@@ -116,14 +118,17 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
|
||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
||||
Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow,
|
||||
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
|
||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
|
||||
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
||||
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
|
||||
исключений frontend нет. Локально можно выполнить те же команды:
|
||||
`npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше
|
||||
блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
|
||||
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
|
||||
Локально можно выполнить те же команды:
|
||||
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
|
||||
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
|
||||
`npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry;
|
||||
проверка секретов выполняется локально и не выводит их значения.
|
||||
|
||||
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
||||
@@ -203,7 +208,8 @@ Checkout frontend содержит все входы сборки и прове
|
||||
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
|
||||
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
|
||||
Runtime URL API задаётся окружением, а не расположением другого репозитория.
|
||||
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
|
||||
Разработка начинается с Issue; lightweight или SDD выбирается по критериям
|
||||
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
|
||||
|
||||
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
|
||||
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
|
||||
@@ -211,36 +217,61 @@ preset по имени из `.release-it.json`. Версия preset закреп
|
||||
## Релизы
|
||||
|
||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
||||
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
||||
`release/hotfix-<name>` и проходит тот же процесс.
|
||||
```text
|
||||
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||
```
|
||||
|
||||
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
|
||||
|
||||
```bash
|
||||
git fetch -q origin
|
||||
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
|
||||
```
|
||||
|
||||
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
|
||||
включая `spec:`. Версии вручную не повышай.
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
|
||||
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
|
||||
`nvm use --silent`, `npm ci`.
|
||||
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
||||
изменился, согласуй историю и повтори проверки кандидата.
|
||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
||||
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
||||
существующий кандидат без повторного повышения версии.
|
||||
|
||||
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
|
||||
без нового bump; до merge исправь и проверь существующий кандидат без
|
||||
повторного повышения версии.
|
||||
|
||||
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
|
||||
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
|
||||
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
|
||||
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
|
||||
затем merge `main → develop` для восстановления инварианта.
|
||||
|
||||
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
||||
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
|
||||
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
|
||||
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
|
||||
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
|
||||
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||
|
||||
@@ -262,14 +293,14 @@ Library build использует отдельный Vite config без app plu
|
||||
Начальная версия manifest взята из приложения; отдельный release-процесс
|
||||
и публикация не настроены.
|
||||
|
||||
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
|
||||
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
|
||||
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
|
||||
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
|
||||
цвет наследуется через `currentColor`, SVG props можно передать явно.
|
||||
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
|
||||
доступное имя задавайте кнопке. Standalone пример:
|
||||
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`.
|
||||
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
|
||||
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
|
||||
Restore/category SVG и предметный BankOperationIcon остаются в приложении.
|
||||
Private `@wealthledger/icons` содержит все SVG приложения: действия,
|
||||
навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет
|
||||
ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook
|
||||
подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования
|
||||
иконок. Category/kind mapping остаётся в model `entities/bank-card`.
|
||||
|
||||
Статичные иконки принимают `IconProps`: `size` по умолчанию 24,
|
||||
`strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная,
|
||||
имя принадлежит действию. Для самостоятельного изображения передайте
|
||||
`aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`.
|
||||
Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
module.exports = {
|
||||
defaultIgnores: false,
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
ignores: [
|
||||
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||
],
|
||||
rules: {
|
||||
"references-empty": [2, "never"],
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
from: {
|
||||
path: "^(src|packages/[^/]+/src)/",
|
||||
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
|
||||
},
|
||||
name: "production-must-not-depend-on-test-code",
|
||||
severity: "error",
|
||||
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
|
||||
},
|
||||
{
|
||||
from: {},
|
||||
name: "no-unresolved",
|
||||
|
||||
@@ -17,12 +17,21 @@ http {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
# One shared budget: the external proxy's client-IP forwarding is not verified.
|
||||
map "$request_method:$uri" $auth_admission_key {
|
||||
default "";
|
||||
POST:/api/v1/auth/login auth;
|
||||
POST:/api/v1/auth/register auth;
|
||||
}
|
||||
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
|
||||
limit_req_status 429;
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||
location /api/ {
|
||||
limit_req zone=auth_admission burst=5 nodelay;
|
||||
set $backend http://backend:3000;
|
||||
proxy_pass $backend$request_uri;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
const readability = {
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
|
||||
function checkParameters(node) {
|
||||
const last = node.params.at(-1);
|
||||
if (
|
||||
last?.type === "Identifier"
|
||||
&& !last.optional
|
||||
&& last.typeAnnotation?.typeAnnotation.type === "TSUnionType"
|
||||
&& last.typeAnnotation.typeAnnotation.types.some(
|
||||
type => type.type === "TSUndefinedKeyword",
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "optional", node: last });
|
||||
}
|
||||
|
||||
return {
|
||||
ArrowFunctionExpression: checkParameters,
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === "MemberExpression"
|
||||
&& ["refine", "superRefine"].includes(node.callee.property.name)
|
||||
&& !filename.endsWith(".schema.ts")
|
||||
)
|
||||
context.report({ messageId: "schema", node });
|
||||
},
|
||||
FunctionDeclaration(node) {
|
||||
checkParameters(node);
|
||||
if (node.id?.name?.endsWith?.("ViewModel"))
|
||||
context.report({ messageId: "vmName", node: node.id });
|
||||
},
|
||||
FunctionExpression: checkParameters,
|
||||
ImportDeclaration(node) {
|
||||
if (
|
||||
node.source.value === "zod"
|
||||
&& node.importKind !== "type"
|
||||
&& !filename.endsWith(".schema.ts")
|
||||
)
|
||||
context.report({ messageId: "schema", node });
|
||||
},
|
||||
JSXOpeningElement(node) {
|
||||
if (
|
||||
!filename.includes("/packages/icons/")
|
||||
&& node.name.type === "JSXIdentifier"
|
||||
&& /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test(
|
||||
node.name.name,
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "svg", node });
|
||||
},
|
||||
TSUnionType(node) {
|
||||
const complex
|
||||
= node.types.length > 2
|
||||
|| node.types.some(type => type.type === "TSTypeLiteral");
|
||||
|
||||
if (
|
||||
complex
|
||||
&& node.types.some(
|
||||
(type, index) =>
|
||||
index > 0
|
||||
&& type.loc.start.line === node.types[index - 1].loc.end.line,
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "union", node });
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (
|
||||
!/^[A-Z]/.test(node.id.name ?? "")
|
||||
|| node.init?.type !== "ArrowFunctionExpression"
|
||||
|| node.init.body.type !== "BlockStatement"
|
||||
)
|
||||
return;
|
||||
|
||||
for (const statement of node.init.body.body) {
|
||||
if (statement.type === "FunctionDeclaration")
|
||||
context.report({ messageId: "helper", node: statement });
|
||||
|
||||
else if (statement.type === "VariableDeclaration") {
|
||||
for (const declaration of statement.declarations) {
|
||||
if (
|
||||
["ArrowFunctionExpression", "FunctionExpression"].includes(
|
||||
declaration.init?.type,
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "helper", node: declaration });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
meta: {
|
||||
messages: {
|
||||
helper:
|
||||
"Move helpers outside components; actions belong to the ViewModel.",
|
||||
optional:
|
||||
"Use an optional trailing parameter instead of a union with undefined.",
|
||||
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||
union: "Put complex union members on separate lines.",
|
||||
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
|
||||
},
|
||||
schema: [],
|
||||
type: "suggestion",
|
||||
},
|
||||
};
|
||||
|
||||
function isComponent(node) {
|
||||
const pending = [node];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const child = pending.pop();
|
||||
if (!child) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case "JSXElement": {
|
||||
return true;
|
||||
}
|
||||
|
||||
case "JSXFragment": {
|
||||
return true;
|
||||
}
|
||||
|
||||
case "JSXExpressionContainer": {
|
||||
pending.push(child.expression);
|
||||
continue;
|
||||
}
|
||||
|
||||
case "LogicalExpression": {
|
||||
pending.push(child.right);
|
||||
continue;
|
||||
}
|
||||
|
||||
case "ConditionalExpression": {
|
||||
pending.push(child.consequent, child.alternate);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const jsxSpacing = {
|
||||
create(context) {
|
||||
function check(node) {
|
||||
let previous;
|
||||
|
||||
for (const child of node.children) {
|
||||
if (child.type === "JSXText" && !child.value.trim()) continue;
|
||||
if (isComponent(child)) {
|
||||
if (previous) {
|
||||
const gap = context.sourceCode.text.slice(
|
||||
previous.range[1],
|
||||
child.range[0],
|
||||
);
|
||||
|
||||
if (!/\n\s*\n/.test(gap))
|
||||
context.report({
|
||||
fix: fixer => fixer.insertTextAfter(previous, "\n"),
|
||||
messageId: "space",
|
||||
node: child,
|
||||
});
|
||||
}
|
||||
|
||||
previous = child;
|
||||
}
|
||||
else previous = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { JSXElement: check, JSXFragment: check };
|
||||
},
|
||||
meta: {
|
||||
fixable: "whitespace",
|
||||
messages: { space: "Separate sibling JSX components with a blank line." },
|
||||
schema: [],
|
||||
type: "layout",
|
||||
},
|
||||
};
|
||||
|
||||
export default { rules: { "jsx-spacing": jsxSpacing, readability } };
|
||||
+206
-45
@@ -20,10 +20,24 @@ import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
import frontend from "./eslint-rules.mjs";
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||
plugins: { frontend },
|
||||
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
|
||||
},
|
||||
globalIgnores([
|
||||
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**",
|
||||
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**",
|
||||
"**/dist/**",
|
||||
"dist-ssr/**",
|
||||
"storybook-static/**",
|
||||
"coverage/**",
|
||||
".vitest/**",
|
||||
".scannerwork/**",
|
||||
"src/shared/api/generated/**",
|
||||
"src/shared/api/spec/**",
|
||||
]),
|
||||
{
|
||||
extends: [
|
||||
@@ -35,32 +49,91 @@ export default defineConfig(
|
||||
security.configs.recommended,
|
||||
noUnsanitized.configs.recommended,
|
||||
perfectionist.configs["recommended-natural"],
|
||||
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }),
|
||||
stylistic.configs.customize({
|
||||
indent: "tab",
|
||||
jsx: true,
|
||||
quotes: "double",
|
||||
semi: true,
|
||||
}),
|
||||
],
|
||||
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
||||
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||||
rules: {
|
||||
// A comma disambiguates generic arrow functions from JSX in TSX.
|
||||
"@stylistic/comma-dangle": ["error", {
|
||||
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline",
|
||||
functions: "always-multiline", generics: "ignore", imports: "always-multiline",
|
||||
objects: "always-multiline", tuples: "always-multiline",
|
||||
}],
|
||||
"@stylistic/comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
arrays: "always-multiline",
|
||||
enums: "always-multiline",
|
||||
exports: "always-multiline",
|
||||
functions: "always-multiline",
|
||||
generics: "ignore",
|
||||
imports: "always-multiline",
|
||||
objects: "always-multiline",
|
||||
tuples: "always-multiline",
|
||||
},
|
||||
],
|
||||
|
||||
// Keep text/interpolation together: splitting can change rendered whitespace.
|
||||
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
|
||||
"@stylistic/max-len": [
|
||||
"error",
|
||||
{
|
||||
code: 80,
|
||||
ignoreComments: true,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreTrailingComments: true,
|
||||
ignoreUrls: true,
|
||||
tabWidth: 2,
|
||||
},
|
||||
],
|
||||
|
||||
"@stylistic/multiline-ternary": ["error", "always-multiline"],
|
||||
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
||||
"@stylistic/operator-linebreak": ["error", "before"],
|
||||
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }],
|
||||
"@stylistic/padding-line-between-statements": [
|
||||
"error",
|
||||
{ blankLine: "always", next: "return", prev: "*" },
|
||||
{
|
||||
blankLine: "always",
|
||||
next: "*",
|
||||
prev: ["multiline-const", "multiline-let", "block-like", "type"],
|
||||
},
|
||||
{
|
||||
blankLine: "always",
|
||||
next: ["multiline-const", "multiline-let", "block-like", "type"],
|
||||
prev: "*",
|
||||
},
|
||||
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
|
||||
],
|
||||
"no-nested-ternary": "error",
|
||||
"perfectionist/sort-classes": [
|
||||
"error",
|
||||
{
|
||||
groups: ["static-method", "property", "method", "unknown"],
|
||||
type: "natural",
|
||||
},
|
||||
],
|
||||
// Preserve the repository's component, hook and config file naming conventions.
|
||||
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }],
|
||||
"unicorn/filename-case": [
|
||||
"error",
|
||||
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
|
||||
],
|
||||
// React uses Props/Ref names, including the recommended React ref-name rule.
|
||||
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }],
|
||||
// React, Mantine and Fetch accept null at their documented call boundaries.
|
||||
"unicorn/no-null": ["error", { checkArguments: false }],
|
||||
"unicorn/name-replacements": [
|
||||
"error",
|
||||
{ replacements: { props: false, ref: false } },
|
||||
],
|
||||
// API DTOs, React refs and Mantine use null as part of their contracts.
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/prefer-ternary": ["error", "only-single-line"],
|
||||
},
|
||||
settings: {
|
||||
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })],
|
||||
"import-x/resolver-next": [
|
||||
createTypeScriptImportResolver({ project: "tsconfig.json" }),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,18 +141,39 @@ export default defineConfig(
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
{
|
||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
||||
extends: [
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
importX.flatConfigs.typescript,
|
||||
],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
// React ignores event handler return values; floating promises are still checked in bodies.
|
||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]],
|
||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"],
|
||||
extends: [
|
||||
react.configs["recommended-type-checked"],
|
||||
hooks.configs.flat.recommended,
|
||||
query.configs["flat/recommended"],
|
||||
],
|
||||
files: [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"packages/*/src/**/*.{ts,tsx}",
|
||||
".storybook/preview.tsx",
|
||||
],
|
||||
languageOptions: { globals: globals.browser },
|
||||
rules: {
|
||||
"import-x/exports-last": "error",
|
||||
@@ -91,14 +185,59 @@ export default defineConfig(
|
||||
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
||||
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.view.tsx"],
|
||||
rules: {
|
||||
"no-restricted-imports": ["error", {
|
||||
paths: [
|
||||
{
|
||||
allowTypeImports: true,
|
||||
message: "Query lifecycle belongs to the data VM.",
|
||||
name: "@tanstack/react-query",
|
||||
},
|
||||
{
|
||||
allowTypeImports: true,
|
||||
message: "Transport belongs to shared/api and the data VM.",
|
||||
name: "openapi-fetch",
|
||||
},
|
||||
{
|
||||
allowTypeImports: true,
|
||||
message: "Transport belongs to shared/api and the data VM.",
|
||||
name: "openapi-react-query",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
allowTypeImports: true,
|
||||
group: ["react-router", "@/entities/*", "@/features/*"],
|
||||
importNamePattern: "^use",
|
||||
message: "Pass route, auth and entity data from the data VM.",
|
||||
},
|
||||
{
|
||||
allowTypeImports: true,
|
||||
group: ["@/shared/api", "@/shared/api/*"],
|
||||
importNamePattern: "^use|^api(?:Query)?Client$",
|
||||
message: "Fetching belongs to the data VM; error helpers are allowed.",
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-magic-numbers": ["error", {
|
||||
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true,
|
||||
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true,
|
||||
}],
|
||||
"@typescript-eslint/no-magic-numbers": [
|
||||
"error",
|
||||
{
|
||||
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
|
||||
ignoreArrayIndexes: true,
|
||||
ignoreDefaultValues: true,
|
||||
ignoreNumericLiteralTypes: true,
|
||||
ignoreTypeIndexes: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -110,51 +249,73 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]],
|
||||
extends: [
|
||||
testingLibrary.configs["flat/react"],
|
||||
jestDom.configs["flat/recommended"],
|
||||
],
|
||||
files: ["**/*.test.tsx"],
|
||||
},
|
||||
storybook.configs["flat/recommended"],
|
||||
{
|
||||
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
||||
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" },
|
||||
rules: {
|
||||
"import-x/exports-last": "off",
|
||||
"import-x/no-default-export": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"packages/*/src/**/*.{ts,tsx}",
|
||||
],
|
||||
ignores: [
|
||||
"**/*.test.{ts,tsx}",
|
||||
"**/*.stories.tsx",
|
||||
"src/test/**",
|
||||
],
|
||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-enum-comparison": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
message: "Use the owning enum member for machine codes and protocol values.",
|
||||
selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
||||
message:
|
||||
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
|
||||
selector:
|
||||
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
|
||||
},
|
||||
{
|
||||
message: "Use the owning enum member instead of a template machine code.",
|
||||
selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
||||
message: "Name the derived prop value before JSX.",
|
||||
selector:
|
||||
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
|
||||
},
|
||||
|
||||
{
|
||||
message:
|
||||
"Use the owning enum member for machine codes and protocol values.",
|
||||
selector:
|
||||
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
|
||||
},
|
||||
{
|
||||
message: "Use an enum member instead of an inline code or protocol value.",
|
||||
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
||||
message:
|
||||
"Use the owning enum member instead of a template machine code.",
|
||||
selector:
|
||||
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
|
||||
},
|
||||
{
|
||||
message: "Use an enum member instead of constructing a code or protocol value.",
|
||||
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
||||
message:
|
||||
"Use an enum member instead of an inline code or protocol value.",
|
||||
selector:
|
||||
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Use an enum member instead of constructing a code or protocol value.",
|
||||
selector:
|
||||
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
|
||||
},
|
||||
{
|
||||
message: "Use a computed enum member as the protocol mapping key.",
|
||||
selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
||||
selector:
|
||||
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
|
||||
},
|
||||
{
|
||||
message: "Compare codes with their enum members, not string literals.",
|
||||
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
||||
message:
|
||||
"Compare codes with their enum members, not string literals.",
|
||||
selector:
|
||||
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>WealthLedger</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
|
||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
||||
|
||||
Generated
+1307
-2646
File diff suppressed because it is too large
Load Diff
+65
-50
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "wealth-ledger-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "24.21.0"
|
||||
"node": "24.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run build:packages && vite",
|
||||
@@ -20,10 +20,10 @@
|
||||
"test:coverage": "npm run build:packages && vitest run --coverage",
|
||||
"test:lint": "node --test scripts/test-eslint.mjs",
|
||||
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
||||
"openapi:check": "node scripts/check-openapi.mjs",
|
||||
"openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check",
|
||||
"knip": "npm run build:packages && knip",
|
||||
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
||||
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
||||
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
||||
"prepare": "husky",
|
||||
"preview": "vite preview",
|
||||
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
||||
@@ -34,85 +34,100 @@
|
||||
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
|
||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@mantine/charts": "9.6.1",
|
||||
"@mantine/core": "9.6.1",
|
||||
"@mantine/dates": "9.6.1",
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@hookform/resolvers": "5.9.2",
|
||||
"@mantine/charts": "9.7.3",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/dates": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@mantine/notifications": "9.7.3",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@wealthledger/icons": "file:packages/icons",
|
||||
"@wealthledger/tokens": "file:packages/tokens",
|
||||
"@wealthledger/ui": "file:packages/ui",
|
||||
"dayjs": "1.11.23",
|
||||
"i18next": "26.4.2",
|
||||
"motion": "13.2.0",
|
||||
"i18next": "26.5.10",
|
||||
"motion": "13.5.1",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"openapi-react-query": "0.5.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.88.0",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-is": "19.3.0",
|
||||
"react-router": "8.3.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-hook-form": "7.91.0",
|
||||
"react-i18next": "17.1.8",
|
||||
"react-is": "19.3.1",
|
||||
"react-router": "8.5.0",
|
||||
"recharts": "3.10.1",
|
||||
"zod": "4.6.4"
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "8.0.5",
|
||||
"@commitlint/cli": "21.2.2",
|
||||
"@commitlint/config-conventional": "21.2.2",
|
||||
"@eslint-react/eslint-plugin": "5.21.0",
|
||||
"@babel/core": "8.0.9",
|
||||
"@commitlint/cli": "21.2.4",
|
||||
"@commitlint/config-conventional": "21.2.4",
|
||||
"@eslint-react/eslint-plugin": "5.28.8",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@release-it/conventional-changelog": "12.0.0",
|
||||
"@release-it/conventional-changelog": "12.0.2",
|
||||
"@rolldown/plugin-babel": "0.2.4",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@storybook/addon-docs": "10.6.3",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@tanstack/eslint-plugin-query": "5.104.0",
|
||||
"@tanstack/eslint-plugin-query": "5.104.5",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitejs/plugin-react": "6.1.2",
|
||||
"@vitest/browser": "5.0.5",
|
||||
"@vitest/browser-playwright": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/eslint-plugin": "1.6.27",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||
"dependency-cruiser": "18.2.0",
|
||||
"eslint": "10.11.0",
|
||||
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||
"dependency-cruiser": "18.7.0",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-jest-dom": "5.10.1",
|
||||
"eslint-plugin-no-unsanitized": "4.1.5",
|
||||
"eslint-plugin-perfectionist": "5.12.1",
|
||||
"eslint-plugin-perfectionist": "5.13.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.7",
|
||||
"eslint-plugin-regexp": "3.3.1",
|
||||
"eslint-plugin-security": "4.1.0",
|
||||
"eslint-plugin-sonarjs": "4.2.1",
|
||||
"eslint-plugin-storybook": "10.6.0",
|
||||
"eslint-plugin-security": "4.2.0",
|
||||
"eslint-plugin-sonarjs": "4.2.2",
|
||||
"eslint-plugin-storybook": "10.6.3",
|
||||
"eslint-plugin-testing-library": "7.16.2",
|
||||
"eslint-plugin-unicorn": "76.0.0",
|
||||
"globals": "17.12.0",
|
||||
"globals": "17.13.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.1",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "17.5.1",
|
||||
"jsdom": "30.1.3",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "17.6.0",
|
||||
"minimatch": "file:scripts/minimatch-compat",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"playwright": "1.63.0",
|
||||
"release-it": "21.0.2",
|
||||
"storybook": "10.6.0",
|
||||
"playwright": "1.64.1",
|
||||
"release-it": "21.1.1",
|
||||
"storybook": "10.6.3",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.70.1",
|
||||
"vite": "8.3.0",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "2.0.0",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.6",
|
||||
"undici": "8.12.3"
|
||||
},
|
||||
"@vitest/browser": "5.0.5",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"@vitest/mocker": "5.0.5",
|
||||
"minimatch": "$minimatch",
|
||||
"glob": "14.0.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true,
|
||||
|
||||
@@ -10,21 +10,24 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./styles.css": "./dist/index.css"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build && tsc -p tsconfig.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "19.3.0"
|
||||
"react": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const meta = {
|
||||
} satisfies Meta<typeof AddIcon>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Decorative: Story = {
|
||||
@@ -25,9 +26,11 @@ export const Decorative: Story = {
|
||||
<button aria-label="Добавить пример" type="button">
|
||||
<AddIcon size={20} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
<button aria-label="Изменить пример" type="button">
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
|
||||
<button aria-label="Удалить пример" type="button">
|
||||
<DeleteIcon size={18} />
|
||||
</button>
|
||||
@@ -39,7 +42,9 @@ export const Meaningful: Story = {
|
||||
render: () => (
|
||||
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
|
||||
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
|
||||
|
||||
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
|
||||
|
||||
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AddIcon, DeleteIcon, EditIcon } from "./index";
|
||||
import { AddIcon, DeleteIcon, EditIcon, OperationIcon } from "./index";
|
||||
|
||||
describe("icon accessibility", () => {
|
||||
it.each([AddIcon, EditIcon, DeleteIcon])(
|
||||
@@ -12,6 +12,7 @@ describe("icon accessibility", () => {
|
||||
<Icon />
|
||||
</button>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
@@ -33,4 +34,16 @@ describe("icon accessibility", () => {
|
||||
expect(icon).toHaveAttribute("stroke", "currentColor");
|
||||
},
|
||||
);
|
||||
it("keeps operation icons decorative", () => {
|
||||
render(
|
||||
<button aria-label="Операция" type="button">
|
||||
<OperationIcon color="orange" variant="home" />
|
||||
</button>,
|
||||
);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
// eslint-disable-next-line testing-library/no-node-access -- Decorative SVG has no accessible role.
|
||||
expect(screen.getByRole("button").querySelector("svg")).toHaveAttribute(
|
||||
"aria-hidden", "true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
|
||||
& path {
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { BasketIcon } from "./svg/BasketIcon";
|
||||
import { CarIcon } from "./svg/CarIcon";
|
||||
import { CashWithdrawalIcon } from "./svg/CashWithdrawalIcon";
|
||||
import { CoffeeIcon } from "./svg/CoffeeIcon";
|
||||
import { DepositIcon } from "./svg/DepositIcon";
|
||||
import { DiceIcon } from "./svg/DiceIcon";
|
||||
import { DownloadIcon } from "./svg/DownloadIcon";
|
||||
import { EducationIcon } from "./svg/EducationIcon";
|
||||
import { GridIcon } from "./svg/GridIcon";
|
||||
import { GrowthIcon } from "./svg/GrowthIcon";
|
||||
import { HomeIcon } from "./svg/HomeIcon";
|
||||
import { MedicalIcon } from "./svg/MedicalIcon";
|
||||
import { MusicIcon } from "./svg/MusicIcon";
|
||||
import { RefundIcon } from "./svg/RefundIcon";
|
||||
import { RepaymentIcon } from "./svg/RepaymentIcon";
|
||||
import { ServerIcon } from "./svg/ServerIcon";
|
||||
import { ShieldIcon } from "./svg/ShieldIcon";
|
||||
import { ShoppingIcon } from "./svg/ShoppingIcon";
|
||||
import { SmokingIcon } from "./svg/SmokingIcon";
|
||||
import { SubscriptionsIcon } from "./svg/SubscriptionsIcon";
|
||||
import { TaxiIcon } from "./svg/TaxiIcon";
|
||||
import { TopupIcon } from "./svg/TopupIcon";
|
||||
import { TransferIcon } from "./svg/TransferIcon";
|
||||
import { TravelIcon } from "./svg/TravelIcon";
|
||||
import { WifiIcon } from "./svg/WifiIcon";
|
||||
import { WineIcon } from "./svg/WineIcon";
|
||||
|
||||
const icons = {
|
||||
basket: BasketIcon,
|
||||
car: CarIcon,
|
||||
cashWithdrawal: CashWithdrawalIcon,
|
||||
coffee: CoffeeIcon,
|
||||
deposit: DepositIcon,
|
||||
dice: DiceIcon,
|
||||
download: DownloadIcon,
|
||||
education: EducationIcon,
|
||||
grid: GridIcon,
|
||||
growth: GrowthIcon,
|
||||
home: HomeIcon,
|
||||
medical: MedicalIcon,
|
||||
music: MusicIcon,
|
||||
refund: RefundIcon,
|
||||
repayment: RepaymentIcon,
|
||||
server: ServerIcon,
|
||||
shield: ShieldIcon,
|
||||
shopping: ShoppingIcon,
|
||||
smoking: SmokingIcon,
|
||||
subscriptions: SubscriptionsIcon,
|
||||
taxi: TaxiIcon,
|
||||
topup: TopupIcon,
|
||||
transfer: TransferIcon,
|
||||
travel: TravelIcon,
|
||||
wifi: WifiIcon,
|
||||
wine: WineIcon,
|
||||
};
|
||||
|
||||
type OperationIconProps = {
|
||||
color: string;
|
||||
variant: keyof typeof icons;
|
||||
};
|
||||
|
||||
const OperationIcon: FC<OperationIconProps> = (props) => {
|
||||
const { color, variant } = props;
|
||||
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed key of the static icon registry.
|
||||
const Icon = icons[variant];
|
||||
|
||||
return <Icon style={{ color: `var(--mantine-color-${color}-7)` }} />;
|
||||
};
|
||||
|
||||
export { OperationIcon, type OperationIconProps };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const BasketIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.basket}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 9h18l-2 11H5L3 9Z" pathLength={1} />
|
||||
|
||||
<path d="M8 9l4-6 4 6" pathLength={1} />
|
||||
|
||||
<path d="M9 13v3" pathLength={1} />
|
||||
|
||||
<path d="M15 13v3" pathLength={1} />
|
||||
|
||||
<path d="M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { BasketIcon };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const CarIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.car}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2" pathLength={1} />
|
||||
|
||||
<path d="M8 16h8" pathLength={1} />
|
||||
|
||||
<path d="M6 11h12m-6-4v4" pathLength={1} />
|
||||
|
||||
<path d="M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
|
||||
|
||||
<path d="M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CarIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const CashWithdrawalIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 10V3h16v7" pathLength={1} />
|
||||
|
||||
<path d="M7 7h10v14H7V7Z" pathLength={1} />
|
||||
|
||||
<path d="M7 11h10" pathLength={1} />
|
||||
|
||||
<path d="M10 16h4" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CashWithdrawalIcon };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const CoffeeIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.coffee}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z" pathLength={1} />
|
||||
|
||||
<path d="M16 9h2a3 3 0 0 1 0 6h-2" pathLength={1} />
|
||||
|
||||
<path d="M8 6c-2-1 2-2 0-4" pathLength={1} />
|
||||
|
||||
<path d="M12 6c-2-1 2-2 0-4" pathLength={1} />
|
||||
|
||||
<path d="M3 22h15" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CoffeeIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const DepositIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.deposit}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0" pathLength={1} />
|
||||
|
||||
<path d="M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3" pathLength={1} />
|
||||
|
||||
<path d="M4 16c0 4 16 4 16 0" pathLength={1} />
|
||||
|
||||
<path d="M4 19c0 4 16 4 16 0" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { DepositIcon };
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const DiceIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" pathLength={1} />
|
||||
|
||||
<path d="M7 7h.01" pathLength={1} />
|
||||
|
||||
<path d="M17 7h.01" pathLength={1} />
|
||||
|
||||
<path d="M12 12h.01" pathLength={1} />
|
||||
|
||||
<path d="M7 17h.01" pathLength={1} />
|
||||
|
||||
<path d="M17 17h.01" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { DiceIcon };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const DownloadIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 3h18v13H3V3Z" pathLength={1} />
|
||||
|
||||
<path d="M8 21h8" pathLength={1} />
|
||||
|
||||
<path d="M12 16v5" pathLength={1} />
|
||||
|
||||
<path d="M12 6v7" pathLength={1} />
|
||||
|
||||
<path d="M9 10l3 3 3-3" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { DownloadIcon };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const EducationIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M2 8l10-5 10 5-10 5L2 8Z" pathLength={1} />
|
||||
|
||||
<path d="M6 10v7c4 3 8 3 12 0v-7" pathLength={1} />
|
||||
|
||||
<path d="M22 8v9" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { EducationIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const GridIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.grid}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 4h6v6H4V4Z" pathLength={1} />
|
||||
|
||||
<path d="M14 4h6v6h-6V4Z" pathLength={1} />
|
||||
|
||||
<path d="M4 14h6v6H4v-6Z" pathLength={1} />
|
||||
|
||||
<path d="M14 14h6v6h-6v-6Z" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { GridIcon };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const GrowthIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.growth}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 3v18h18" pathLength={1} />
|
||||
|
||||
<path d="M6 15l5-5 4 3 6-8" pathLength={1} />
|
||||
|
||||
<path d="M16 5h5v5" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { GrowthIcon };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const HomeIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 11l9-8 9 8" pathLength={1} />
|
||||
|
||||
<path d="M5 10v11h14V10" pathLength={1} />
|
||||
|
||||
<path d="M9 21v-8h6v8" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomeIcon };
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const MedicalIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { MedicalIcon };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const MusicIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M9 5v14a3 3 0 1 1-3-3h3" pathLength={1} />
|
||||
|
||||
<path d="M9 5l11-2v14a3 3 0 1 1-3-3h3" pathLength={1} />
|
||||
|
||||
<path d="M9 9l11-2" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { MusicIcon };
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const RefundIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M8 4L3 9l5 5" pathLength={1} />
|
||||
|
||||
<path d="M3 9h11a7 7 0 0 1 0 14" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { RefundIcon };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const RepaymentIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.repayment}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 5h18v14H3V5Z" pathLength={1} />
|
||||
|
||||
<path d="M3 9h18" pathLength={1} />
|
||||
|
||||
<path d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { RepaymentIcon };
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const ServerIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.server}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 3h18v7H3V3Z" pathLength={1} />
|
||||
|
||||
<path d="M3 14h18v7H3v-7Z" pathLength={1} />
|
||||
|
||||
<path d="M7 6.5h.01" pathLength={1} />
|
||||
|
||||
<path d="M7 17.5h.01" pathLength={1} />
|
||||
|
||||
<path d="M15 6.5h3" pathLength={1} />
|
||||
|
||||
<path d="M15 17.5h3" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { ServerIcon };
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const ShieldIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z" pathLength={1} />
|
||||
|
||||
<path d="M8 12l3 3 5-6" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { ShieldIcon };
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const ShoppingIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M5 7h14l2 14H3L5 7Z" pathLength={1} />
|
||||
|
||||
<path d="M8 8V6a4 4 0 0 1 8 0v2" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { ShoppingIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const SmokingIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 14h18v5H3v-5Z" pathLength={1} />
|
||||
|
||||
<path d="M16 14v5" pathLength={1} />
|
||||
|
||||
<path d="M8 10V8c0-2 3-2 3-4V2" pathLength={1} />
|
||||
|
||||
<path d="M15 10V8c0-2 3-2 3-4" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { SmokingIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const SubscriptionsIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 10a8 8 0 0 1 13-6l3 3" pathLength={1} />
|
||||
|
||||
<path d="M20 3v4h-4" pathLength={1} />
|
||||
|
||||
<path d="M20 14a8 8 0 0 1-13 6l-3-3" pathLength={1} />
|
||||
|
||||
<path d="M4 21v-4h4" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { SubscriptionsIcon };
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const TaxiIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 10l2-6h12l2 6" pathLength={1} />
|
||||
|
||||
<path d="M3 10h18v8H3v-8Z" pathLength={1} />
|
||||
|
||||
<path d="M6 18v3" pathLength={1} />
|
||||
|
||||
<path d="M18 18v3" pathLength={1} />
|
||||
|
||||
<path d="M6 14h2" pathLength={1} />
|
||||
|
||||
<path d="M16 14h2" pathLength={1} />
|
||||
|
||||
<path d="M10 4V2h4v2" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { TaxiIcon };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const TopupIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.topup}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M20 8V5H5a2 2 0 0 1 0-4h13v4" pathLength={1} />
|
||||
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h15V8H5" pathLength={1} />
|
||||
|
||||
<path d="M16 12v5m-2.5-2.5h5" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { TopupIcon };
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const TransferIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.transfer}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 7h18l-4-4" pathLength={1} />
|
||||
|
||||
<path d="M21 17H3l4 4" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { TransferIcon };
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const TravelIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { TravelIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const WifiIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.wifi}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 7a14 14 0 0 1 18 0" pathLength={1} />
|
||||
|
||||
<path d="M6 11a9 9 0 0 1 12 0" pathLength={1} />
|
||||
|
||||
<path d="M9 15a4 4 0 0 1 6 0" pathLength={1} />
|
||||
|
||||
<path d="M12 19h.01" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { WifiIcon };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../../types";
|
||||
|
||||
import { bindIconAnimation } from "../../bind-icon-animation";
|
||||
import classes from "../OperationIcon.module.css";
|
||||
|
||||
const WineIcon: FC<IconProps> = (props) => {
|
||||
const { size = 24, ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${classes.wine}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M7 3h10v5a5 5 0 0 1-10 0V3Z" pathLength={1} />
|
||||
|
||||
<path d="M7 7h10v1a5 5 0 0 1-10 0V7Z" pathLength={1} />
|
||||
|
||||
<path d="M12 13v8" pathLength={1} />
|
||||
|
||||
<path d="M8 21h8" pathLength={1} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { WineIcon };
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
+6
@@ -9,25 +9,30 @@ it("finishes every delayed part after leaving and does not restart on quick re-e
|
||||
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
|
||||
matches: true,
|
||||
} as MediaQueryList);
|
||||
|
||||
const { container } = render(
|
||||
<button type="button">
|
||||
<svg aria-hidden="true" ref={bindIconAnimation} />
|
||||
Category
|
||||
</button>,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
|
||||
const icon = container.querySelector("svg");
|
||||
if (!icon) throw new Error("Missing icon");
|
||||
const target = screen.getByRole("button");
|
||||
let finishFirst!: () => void;
|
||||
let finishLast!: () => void;
|
||||
|
||||
const getAnimations = vi.fn(() => {
|
||||
const first = Promise.withResolvers<void>();
|
||||
const last = Promise.withResolvers<void>();
|
||||
finishFirst = first.resolve;
|
||||
finishLast = last.resolve;
|
||||
|
||||
return [{ finished: first.promise }, { finished: last.promise }];
|
||||
});
|
||||
|
||||
Object.defineProperty(icon, "getAnimations", { value: getAnimations });
|
||||
fireEvent.mouseEnter(target);
|
||||
fireEvent.mouseLeave(target);
|
||||
@@ -53,6 +58,7 @@ it("keeps playing after keyboard blur and releases cancelled animations", async
|
||||
Category
|
||||
</button>,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
|
||||
const icon = container.querySelector("svg");
|
||||
if (!icon) throw new Error("Missing icon");
|
||||
@@ -13,6 +13,7 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
|
||||
delete icon.dataset.iconAnimating;
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
|
||||
icon.dataset.iconAnimating = "";
|
||||
@@ -27,21 +28,25 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
|
||||
release();
|
||||
});
|
||||
};
|
||||
|
||||
const enter = () => {
|
||||
if (!matchMedia("(hover: hover)").matches) return;
|
||||
isHovered = true;
|
||||
start();
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
isHovered = false;
|
||||
release();
|
||||
};
|
||||
|
||||
const focus = () => {
|
||||
isFocused
|
||||
= target.matches(":focus-visible")
|
||||
|| !!target.querySelector(":focus-visible");
|
||||
if (isFocused) start();
|
||||
};
|
||||
|
||||
const blur = (event: FocusEvent) => {
|
||||
if (
|
||||
event.relatedTarget instanceof Node
|
||||
@@ -51,10 +56,12 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => {
|
||||
isFocused = false;
|
||||
release();
|
||||
};
|
||||
|
||||
target.addEventListener("mouseenter", enter);
|
||||
target.addEventListener("mouseleave", leave);
|
||||
target.addEventListener("focusin", focus);
|
||||
target.addEventListener("focusout", blur);
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
target.removeEventListener("mouseenter", enter);
|
||||
@@ -1,4 +1,17 @@
|
||||
export {
|
||||
OperationIcon,
|
||||
type OperationIconProps,
|
||||
} from "./OperationIcon/OperationIcon";
|
||||
export { AddIcon } from "./svg/AddIcon";
|
||||
export { BondsIcon } from "./svg/BondsIcon";
|
||||
export { CardsIcon } from "./svg/CardsIcon";
|
||||
export { CategoriesIcon } from "./svg/CategoriesIcon";
|
||||
export { ContactlessIcon } from "./svg/ContactlessIcon";
|
||||
export { DeleteIcon } from "./svg/DeleteIcon";
|
||||
export { EditIcon } from "./svg/EditIcon";
|
||||
export { PortfolioIcon } from "./svg/PortfolioIcon";
|
||||
export { RestoreIcon } from "./svg/RestoreIcon";
|
||||
export { SettingsIcon } from "./svg/SettingsIcon";
|
||||
export { StocksIcon } from "./svg/StocksIcon";
|
||||
export { WalletIcon } from "./svg/WalletIcon";
|
||||
export type { IconProps } from "./types";
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
.rubles {
|
||||
pointer-events: none;
|
||||
color: var(--mantine-color-green-8);
|
||||
stroke: currentColor;
|
||||
|
||||
& path {
|
||||
opacity: 0;
|
||||
--flight-x: -7px;
|
||||
--flight-rotation: -20deg;
|
||||
--flight-delay: 0ms;
|
||||
}
|
||||
|
||||
& path:nth-child(2) {
|
||||
--flight-x: 0px;
|
||||
--flight-rotation: 8deg;
|
||||
--flight-delay: 120ms;
|
||||
}
|
||||
|
||||
& path:nth-child(3) {
|
||||
--flight-x: 7px;
|
||||
--flight-rotation: 24deg;
|
||||
--flight-delay: 240ms;
|
||||
}
|
||||
}
|
||||
|
||||
.portfolioRuble {
|
||||
stroke: var(--mantine-color-green-8);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stockArrow {
|
||||
transform-origin: 21px 5px;
|
||||
}
|
||||
|
||||
.topSlider,
|
||||
.bottomSlider {
|
||||
fill: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.drawStroke,
|
||||
.couponLines path {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
|
||||
.topSlider {
|
||||
--slider-offset: 3px;
|
||||
--slider-delay: 0ms;
|
||||
}
|
||||
|
||||
.bottomSlider {
|
||||
--slider-offset: -3px;
|
||||
--slider-delay: 160ms;
|
||||
}
|
||||
|
||||
.couponLines path:nth-child(2) {
|
||||
--line-delay: 140ms;
|
||||
}
|
||||
|
||||
.couponLines path:nth-child(3) {
|
||||
--line-delay: 280ms;
|
||||
}
|
||||
|
||||
svg[data-icon-animating] {
|
||||
.rubles path {
|
||||
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
|
||||
}
|
||||
.portfolioHandle {
|
||||
animation: handle-lift 1200ms ease-in-out;
|
||||
}
|
||||
.portfolioLid {
|
||||
animation: lid-open 1200ms ease-in-out;
|
||||
}
|
||||
.portfolioRuble {
|
||||
animation: portfolio-ruble-reveal 1200ms ease-in-out;
|
||||
}
|
||||
.stockArrow {
|
||||
animation: arrow-pop 300ms ease-out 400ms both;
|
||||
}
|
||||
.couponLines path {
|
||||
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
|
||||
}
|
||||
.drawStroke {
|
||||
animation: stroke-draw 450ms ease-out;
|
||||
}
|
||||
:is(.topSlider, .bottomSlider) {
|
||||
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ruble-fly {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 4px);
|
||||
}
|
||||
20%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes handle-lift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
40%,
|
||||
75% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lid-open {
|
||||
0%,
|
||||
15%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40%,
|
||||
75% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes portfolio-ruble-reveal {
|
||||
0%,
|
||||
25%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
40%,
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arrow-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stroke-draw {
|
||||
0% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slider-slide {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(var(--slider-offset));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
|
||||
|
||||
const AddIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
import { bindIconAnimation } from "../bind-icon-animation";
|
||||
import classes from "../navigation.module.css";
|
||||
|
||||
const BondsIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
|
||||
|
||||
<g className={classes.couponLines}>
|
||||
<path d="M9 7h6" pathLength={1} />
|
||||
|
||||
<path d="M9 11h6" pathLength={1} />
|
||||
|
||||
<path d="M9 15h3" pathLength={1} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { BondsIcon };
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
import { bindIconAnimation } from "../bind-icon-animation";
|
||||
import classes from "../navigation.module.css";
|
||||
|
||||
const CardsIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
|
||||
|
||||
<g className={classes.rubles}>
|
||||
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
|
||||
|
||||
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
|
||||
|
||||
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CardsIcon };
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const CategoriesIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CategoriesIcon };
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const ContactlessIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path
|
||||
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { ContactlessIcon };
|
||||
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
|
||||
|
||||
const DeleteIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
|
||||
|
||||
const EditIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
import { bindIconAnimation } from "../bind-icon-animation";
|
||||
import classes from "../navigation.module.css";
|
||||
|
||||
const PortfolioIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 10v9h18v-9" />
|
||||
|
||||
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
|
||||
|
||||
<path
|
||||
className={classes.portfolioLid}
|
||||
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
|
||||
/>
|
||||
|
||||
<path
|
||||
className={classes.portfolioRuble}
|
||||
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { PortfolioIcon };
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const RestoreIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { RestoreIcon };
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
import { bindIconAnimation } from "../bind-icon-animation";
|
||||
import classes from "../navigation.module.css";
|
||||
|
||||
const SettingsIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
|
||||
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
|
||||
|
||||
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { SettingsIcon };
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
import { bindIconAnimation } from "../bind-icon-animation";
|
||||
import classes from "../navigation.module.css";
|
||||
|
||||
const StocksIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 3v18h18" />
|
||||
|
||||
<path
|
||||
className={classes.drawStroke}
|
||||
d="M7 14l5-5 4 3 5-7"
|
||||
pathLength={1}
|
||||
/>
|
||||
|
||||
<path className={classes.stockArrow} d="M16 5h5v5" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { StocksIcon };
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const WalletIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
{...svgProps}
|
||||
>
|
||||
<rect height="15" rx="3" width="18" x="3" y="5" />
|
||||
|
||||
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { WalletIcon };
|
||||
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"DOM"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"include": ["src/index.ts"]
|
||||
"include": [
|
||||
"src/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,18 +27,18 @@
|
||||
"@wealthledger/tokens": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "9.6.1",
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/react": "19.3.0",
|
||||
"storybook": "10.6.0",
|
||||
"storybook": "10.6.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import "@mantine/core/styles.css";
|
||||
|
||||
import "../styles.css";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import { cssVariablesResolver, theme } from "../theme";
|
||||
import { EntryCard } from "./EntryCard";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows the full wrapped title without horizontal overflow", () => {
|
||||
const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью";
|
||||
|
||||
render(
|
||||
<MantineProvider cssVariablesResolver={cssVariablesResolver} theme={theme}>
|
||||
<EntryCard
|
||||
initial="Д"
|
||||
metadata="29.09.2026"
|
||||
title={title}
|
||||
tone="positive"
|
||||
value="123 456 789,90 ₽"
|
||||
valueLabel="Дивиденды"
|
||||
wrap
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
const description = screen.getByText(title);
|
||||
expect(description.clientHeight).toBeGreaterThan(0);
|
||||
expect(description.scrollHeight)
|
||||
.toBeLessThanOrEqual(description.clientHeight);
|
||||
expect(document.documentElement.scrollWidth)
|
||||
.toBeLessThanOrEqual(window.innerWidth);
|
||||
});
|
||||
@@ -93,3 +93,13 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row[data-wrap] p {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (min-width: 36em) {
|
||||
.row[data-wrap] {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +35,9 @@ const meta = {
|
||||
} satisfies Meta<typeof EntryCard>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const LongContentAndFallback: Story = {};
|
||||
|
||||
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
|
||||
|
||||
@@ -31,11 +31,13 @@ describe("EntryCard", () => {
|
||||
value: "—",
|
||||
valueLabel: "Неизвестно",
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<MantineProvider>
|
||||
<EntryCard {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// Decorative image has no accessible name; trigger its actual failure event.
|
||||
const image = screen.queryByAltText("");
|
||||
assert(image);
|
||||
|
||||
@@ -13,22 +13,32 @@ type EntryCardProps = {
|
||||
initial: string;
|
||||
metadata: string;
|
||||
title: string;
|
||||
tone: "negative" | "neutral" | "positive";
|
||||
value: string;
|
||||
tone: "negative"
|
||||
| "neutral"
|
||||
| "positive";
|
||||
value: ReactNode;
|
||||
valueLabel: string;
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
const surfaceStyle = props.color
|
||||
? {
|
||||
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const titleLineClamp = props.wrap ? undefined : 1;
|
||||
|
||||
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
|
||||
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
||||
const valueComponent = typeof props.value === "string" ? undefined : "div";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={classes.row}
|
||||
style={
|
||||
(props.color)
|
||||
? ({
|
||||
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
data-wrap={props.wrap || undefined}
|
||||
style={surfaceStyle}
|
||||
withBorder
|
||||
>
|
||||
<Avatar
|
||||
@@ -40,26 +50,33 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
>
|
||||
{props.icon ?? props.initial}
|
||||
</Avatar>
|
||||
|
||||
<Stack className={classes.identity} gap={2}>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
<Text fw={700} lineClamp={titleLineClamp}>
|
||||
{props.title}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{props.metadata}
|
||||
</Text>
|
||||
|
||||
{props.details}
|
||||
</Stack>
|
||||
|
||||
<Stack className={classes.value} gap={2}>
|
||||
<Text className={classes[props.tone]} fw={700}>
|
||||
<Text
|
||||
className={classes[props.tone]}
|
||||
component={valueComponent}
|
||||
fw={700}
|
||||
>
|
||||
{props.value}
|
||||
</Text>
|
||||
<div className={(props.actions) ? (classes.valueFooter) : undefined}>
|
||||
<Text
|
||||
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
|
||||
size="sm"
|
||||
>
|
||||
|
||||
<div className={valueContainerClass}>
|
||||
<Text className={valueLabelClass} size="sm">
|
||||
{props.valueLabel}
|
||||
</Text>
|
||||
|
||||
{Boolean(props.actions) && (
|
||||
<div className={classes.actions}>{props.actions}</div>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,9 @@ const meta = {
|
||||
<Paper p="md" withBorder>
|
||||
<LoadingSkeleton {...arguments_}>
|
||||
<Skeleton h={24} w="60%" />
|
||||
|
||||
<Skeleton h={72} />
|
||||
|
||||
<Skeleton h={16} w="80%" />
|
||||
</LoadingSkeleton>
|
||||
</Paper>
|
||||
@@ -33,5 +35,7 @@ const meta = {
|
||||
} satisfies Meta<typeof LoadingSkeleton>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Initial: Story = { name: "Загрузка области" };
|
||||
|
||||
@@ -41,16 +41,17 @@ const meta = {
|
||||
},
|
||||
render: (arguments_) => {
|
||||
const { onAction, withAction, ...stateProps } = arguments_;
|
||||
|
||||
return (
|
||||
<StateView
|
||||
{...stateProps}
|
||||
action={
|
||||
(withAction)
|
||||
? ((
|
||||
withAction
|
||||
? (
|
||||
<Button onClick={onAction} variant="default">
|
||||
{arguments_.error ? "Повторить локально" : "Выполнить пример"}
|
||||
</Button>
|
||||
))
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -63,6 +64,7 @@ const meta = {
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = { name: "Пустое состояние" };
|
||||
export const EmptyWithAction: Story = {
|
||||
args: { withAction: true },
|
||||
|
||||
@@ -17,7 +17,9 @@ const StateView: FC<StateViewProps> = (props) => {
|
||||
<Paper p="xl" radius="md" role={role} shadow="xs" withBorder>
|
||||
<Stack align="flex-start" gap="sm">
|
||||
<Title order={2}>{title}</Title>
|
||||
|
||||
<Text c="dimmed">{text}</Text>
|
||||
|
||||
{action}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "./styles.css";
|
||||
import {
|
||||
colorsTuple,
|
||||
createTheme,
|
||||
@@ -11,8 +10,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import designTokens from "@wealthledger/tokens/tokens.json";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const { breakpoints, color, radius, shadows, spacing, typography }
|
||||
= designTokens;
|
||||
|
||||
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([name, value]) => [name, convert(value)]),
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
|
||||
+16
-1
@@ -1,15 +1,30 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
|
||||
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"],
|
||||
"timezone": "Europe/Moscow",
|
||||
"labels": ["dependencies"],
|
||||
"dependencyDashboard": true,
|
||||
"rangeStrategy": "pin",
|
||||
"prConcurrentLimit": 5,
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 4am on the first day of the month"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "non-major dependencies"
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "non-major github actions"
|
||||
},
|
||||
{
|
||||
"matchManagers": ["docker-compose", "dockerfile"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "non-major container images"
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"dependencyDashboardApproval": true
|
||||
|
||||
@@ -14,14 +14,20 @@ const { spawnSync } = require("node:child_process");
|
||||
|
||||
test("immutable publication resumes exact digest checks after post-push failure", () => {
|
||||
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
|
||||
|
||||
const workflow = readFileSync(
|
||||
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const block = workflow
|
||||
.split(" - name: Publish immutable release tag\n run: |\n", 2)[1]
|
||||
.split(
|
||||
" - name: Publish immutable release tag\n run: |\n",
|
||||
2,
|
||||
)[1]
|
||||
.split(" - name:", 1)[0]
|
||||
.replaceAll(/^ {10}/gm, "");
|
||||
|
||||
const double = String.raw`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2).join(' ');
|
||||
@@ -39,6 +45,7 @@ if (args.startsWith('manifest inspect')) {
|
||||
console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit');
|
||||
} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
|
||||
`;
|
||||
|
||||
try {
|
||||
for (const name of ["docker", "npm"]) {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
@@ -46,6 +53,7 @@ if (args.startsWith('manifest inspect')) {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
|
||||
}
|
||||
|
||||
const run = (extra = {}) =>
|
||||
spawnSync("/bin/sh", ["-ec", block], {
|
||||
cwd: directory,
|
||||
@@ -59,6 +67,7 @@ if (args.startsWith('manifest inspect')) {
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(run().status, 0);
|
||||
const retry = run();
|
||||
assert.equal(retry.status, 0, retry.stdout + retry.stderr);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import nodePath from "node:path";
|
||||
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
|
||||
);
|
||||
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
|
||||
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
|
||||
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const generated = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
if (generated.status === 0) {
|
||||
const [committed, current] = await Promise.all([
|
||||
readFile(committedPath, "utf8"),
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
readFile(generatedPath, "utf8"),
|
||||
]);
|
||||
if (committed !== current) {
|
||||
console.error(
|
||||
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
|
||||
);
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
process.stderr.write(generated.stderr || generated.stdout);
|
||||
exitCode = generated.status ?? 1;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
process.exitCode = exitCode;
|
||||
@@ -2,14 +2,10 @@ const assert = require("node:assert/strict");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { version } = require("../package.json");
|
||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||
const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim();
|
||||
|
||||
assert.equal(
|
||||
git("status", "--porcelain"),
|
||||
"",
|
||||
"Commit all changes before preparing a release",
|
||||
);
|
||||
const git = (...arguments_) =>
|
||||
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||
|
||||
const tag_ = git(
|
||||
"tag",
|
||||
"--merged",
|
||||
@@ -20,6 +16,7 @@ const tag_ = git(
|
||||
)
|
||||
.split("\n")
|
||||
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
|
||||
assert.equal(
|
||||
version,
|
||||
tag_?.slice(1) ?? "0.0.0",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
|
||||
const minimatch = require("minimatch-11");
|
||||
|
||||
module.exports = {
|
||||
...minimatch,
|
||||
makeRe: (pattern, options = {}) => ({
|
||||
test: path => minimatch.minimatch(path, pattern, options),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
|
||||
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
|
||||
// uses the result via .test(), so a matcher object is enough. Delete this
|
||||
// package and the override once import-x supports minimatch 11.
|
||||
import { minimatch } from "minimatch-11";
|
||||
|
||||
export * from "minimatch-11";
|
||||
|
||||
export const makeRe = (pattern, options = {}) => ({
|
||||
test: path => minimatch(path, pattern, options),
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "minimatch",
|
||||
"version": "11.0.2-compat.0",
|
||||
"private": true,
|
||||
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"import": "./index.js",
|
||||
"require": "./index.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimatch-11": "npm:minimatch@11.0.2"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user