Add docker

This commit is contained in:
Evgenii Saenko
2025-12-17 11:51:25 +03:00
parent a01b46182e
commit 4f6700e0e2
110 changed files with 8838 additions and 181 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
npm-debug.log*
yarn-error.log*
Dockerfile
docker-compose.yml
docs

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

1
.prettierrc Normal file
View File

@@ -0,0 +1 @@
{"printWidth":150,"tabWidth":2,"useTabs":false,"semi":true,"singleQuote":true,"trailingComma":"es5","bracketSpacing":true}

23
AGENTS.md Normal file
View File

@@ -0,0 +1,23 @@
# Repository Guidelines
## Project Structure & Module Organization
The Vite client lives here; runtime code sits under `src`, separated by domain: `src/app` for routing/layout, `src/modules` for feature bundles, `src/providers` for cross-cutting contexts, `src/shared` for reusable UI/logic, `src/api` and `src/stores` for data access and state. Global styles and fonts live in `src/index.css` and `src/assets`. Static files go in `public`. Keep feature-specific assets colocated with their module to ease tree shaking.
## Build, Test, and Development Commands
- `npm install` installs all workspace dependencies; rerun after lockfile updates.
- `npm run dev` starts Vite with fast refresh at http://localhost:5173 for interactive work.
- `npm run build` performs a type-check (`tsc -b`) and production bundle; fail the PR if this fails.
- `npm run lint` applies the shared ESLint config; fix all warnings before review.
- `npm run preview` serves the built `dist` bundle to mirror production.
## Coding Style & Naming Conventions
Use TypeScript with ES modules and React 19. Prefer functional components and hooks; move shared hooks/utilities into `src/shared`. Adopt 2-space indentation, single quotes in TS/TSX, and keep components under 200 lines. Name components in PascalCase (`UserPanel`), hooks in camelCase with a `use` prefix, and files that match their default export (`src/modules/auth/LoginForm.tsx`). Format via Prettier 3.6 before committing.
## Testing Guidelines
Automated tests are not wired yet; add Vitest + React Testing Library as coverage grows. Place specs next to implementation under `__tests__` folders and name them `*.spec.tsx`. Target >80% coverage for new modules and capture edge-case scenarios (loading, error boundaries). Until automation exists, record manual QA steps and verify critical flows via `npm run preview`.
## Commit & Pull Request Guidelines
Use imperative subjects in the form `type(scope): summary`, e.g., `feat(profile): add avatar upload`. Reference issues/Jira IDs in the body and call out breaking changes explicitly. Pull requests should describe intent, list validation commands or screenshots, and link any related backend work. Keep diffs focused (<400 LOC) and ensure `build` and `lint` pass before requesting review.
## Security & Configuration Tips
Do not commit `.env*` files; rely on `.env.example` to document required keys and load values through `import.meta.env`. Treat API endpoints and tokens as secrets managed by the backend. Run `npm audit` monthly and upgrade high-risk dependencies immediately. Avoid embedding credentials or irreversible IDs in client bundles.

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM deps AS build
WORKDIR /app
COPY . .
# Build-time API endpoint override; defaults handled in code.
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
# Override at build time: VITE_API_URL=https://api.example.com docker compose up --build
VITE_API_URL: ${VITE_API_URL:-http://localhost:8080}
ports:
- '8000:80'
restart: unless-stopped

97
docs/architecture.md Normal file
View File

@@ -0,0 +1,97 @@
# Архитектура клиентского приложения
Документ описывает устройство Reactклиента из `src/` и взаимосвязь его модулей, чтобы упростить навигацию по проекту и подключение новых разработчиков.
## Технологический стек
- **React 19 + TypeScript** — основной UIфреймворк (`src/main.tsx`, `src/app/App.tsx`), компоненты пишутся как функции с хуками.
- **Vite** — сборка, локальный дев‑сервер и предпросмотр (`package.json` скрипты `dev`, `build`, `preview`).
- **React Router v7** — маршрутизация клиентского приложения (`src/app/router/routes.tsx`).
- **MobX + mobx-react-lite** — управление состоянием через сторы и модели (`src/stores/*`), привязка к компонентам через `observer`.
- **Material UI 7** — дизайн‑система и тема (`src/shared/theme`), включает Emotion для стилизации.
- **Zod** — валидация сетевых ответов и входных данных (`src/api/**/*.ts`).
## Организация каталогов
```
src/
├─ app/ # точка входа UI, глобальные маршруты и обёртка приложения
├─ modules/ # фичевые модули (публичный сайт, админка, разделы)
├─ shared/ # переиспользуемые UIкомпоненты, хуки, тема
├─ providers/ # Reactпровайдеры, контексты
├─ stores/ # состояние MobX и доменные модели
├─ api/ # HTTPклиент, DTO и SDK для бэкенда
├─ assets/ # картинки и брендинговые ассеты
├─ index.css # глобальные стили
└─ main.tsx # инициализация Reactприложения
```
`public/` хранит статические файлы Vite, а `docs/` отведён под документацию (включая текущий файл).
## Жизненный цикл приложения
1. `src/main.tsx` монтирует `<App />` в StrictMode.
2. `src/app/App.tsx` собирает корневое дерево: `<StoreProvider>``<AppTheme>``<CssBaseline>``<RouterProvider router={router} />`.
3. `src/app/router/routes.tsx` объявляет все маршруты, разделяя публичные страницы (`/`, `/news`, `/services`, `/about`) и защищённую зону `/admin/dashboard` с вложенными разделами.
Благодаря `<Outlet />` внутри `AdminDashboardLayout` (`src/modules/admin/components/dashboard-layout.tsx`) дочерние страницы админки получают общий chrome (AppBar, Drawer, выход из аккаунта).
## Управление состоянием
- `src/stores/root.tsx` инициализирует `RootStore` с коллекциями новостей, услуг и категорий услуг. Экземпляр передаётся в Reactконтекст через `StoreProvider` (`src/providers/store.tsx`), а хук `useStore` (`src/shared/hooks/useStore.ts`) упрощает доступ к нему.
- Каждый доменный объект описан MobXмоделью (например, `NewsModel` и `ServiceModel`), которая инкапсулирует нормализацию данных, вычисляемые геттеры (`href`) и методы `update` / `toJSON`.
- Коллекции (`NewsCollectionModel`, `ServicesCollectionModel`, `ServiceCategoryCollectionModel`) отвечают за загрузку данных из API, трекинг `isLoading`/`error`, пагинацию и дедупликацию через `Map`.
- В компонентах состояние наблюдается через `observer` (см. `src/modules/main/components/services.tsx`, `src/modules/services/pages/service-details.tsx`, `src/modules/admin/components/news/dashboard-news.tsx`), что гарантирует реактивность без ручных `useState`.
## Сетевой слой и API
- `src/api/httpClient.ts` содержит унифицированный `send` (GET/POST/PUT/DELETE), сборщик queryпараметров и работу с токеном администратора в `localStorage`. Ответы проходят через Zodсхемы, а при ошибках выбрасывается `ApiError`.
- В `src/api/*` реализованы специализированные SDK:
- `newsApi`, `servicesApi`, `leadsApi` — публичные чтения.
- `adminApi` — все CRUDоперации админки (авторизация, категории, услуги, новости, администрирование).
- Типы DTO и схемы (`src/api/**/types.ts`) документируют поля и позволяют IDE/TypeScript подсказывать структуры данных по всему приложению.
## UI-слой и тема
- Тема MUI собирается в `src/shared/theme/theme.tsx`: кастомные `colorSchemes`, `typography`, `shadows` и набор component overrides (`shared/theme/customizations/*`). Цветовая схема подключается через CSSпеременные и переключатель режима (`ColorModeSelect`, `ColorModeIconDropdown`).
- Глобальные стили (`src/index.css`) подключают шрифты и базовые переменные.
- Переиспользуемые шапка и подвал (`src/shared/components/header.tsx`, `src/shared/components/footer.tsx`) формируют каркас публичных страниц.
## Фичевые модули
- **Main (`src/modules/main`)** — лендинг: блоки `Hero`, `Services`, `RecentNews`, `Features`, `Partners`, `Feedback`. Использует сторы для вывода последних услуг и новостей, а `usePageTitle` (`src/shared/hooks/usePageTitle.ts`) обновляет `document.title`.
- **Services (`src/modules/services`)** — список и детальная страница услуг. `ServiceDetailsPage` грузит данные по `slug`, отображает карточку, хлебные крошки и форматирует цену через `formatPrice`.
- **News (`src/modules/news`)** — лента с пагинацией, просмотр новости (`NewsDetailsPage`), утилита форматирования дат `formatDate`.
- **About (`src/modules/about`)** — информационная статическая страница.
- **Admin (`src/modules/admin`)** — полноценная панель управления:
- маршруты `/admin` (логин) и `/admin/dashboard/*`;
- `dashboard-layout` отвечает за адаптивную навигацию;
- разделы: новости, услуги, категории, лиды, администраторы;
- формы создания/редактирования (`components/services/service-form.tsx`, `components/news/news-create-form.tsx`, `components/service-categories/*`) используют MUI `TextField`, локальную валидацию и обращения к `adminApi`.
- раздел лидов (`components/leads/dashboard-leads.tsx`) общается с `leadsApi` напрямую и хранит состояние в локальных хуках, поскольку данных немного.
## Поток данных и валидации
1. Компонент фичи (например, `Services` или `AdminDashboardNews`) вызывает методы стора (`services.fetch`, `news.fetchAdmin`).
2. Стор делегирует запрос соответствующему SDK (`servicesApi`, `adminApi`) и обновляет MobXмодели.
3. Компоненты, обёрнутые в `observer`, автоматически перерисовываются и показывают лоадеры/ошибки.
4. Формы в админке валидируют ввод на клиенте, а серверные ошибки ловятся как `ApiError` и выводятся в UI.
Такая цепочка (компонент → стор → API → стор → компонент) делает логику прозрачной и облегчает внедрение новых сущностей.
## Ассеты и стили
- Брендовые изображения и логотипы лежат в `src/assets` и импортируются напрямую в компоненты (например, хедер использует `src/assets/logo.png`).
- Файлы в `public/` доступны по прямым URL и могут использоваться для метатегов или фавиконок.
- Изображения партнёров (`src/assets/partners`) подключаются только внутри лендинга, что упрощает tree-shaking.
## Расширение проекта
Чтобы добавить новую сущность:
1. Создайте Zodсхемы и APIклиент в `src/api/<entity>`.
2. Опишите MobXмодель + коллекцию в `src/stores/<entity>`, инициализируйте её в `RootStore`.
3. Подготовьте UI в отдельном модуле внутри `src/modules/<entity>` и подключите маршруты через `src/app/router/routes.tsx`.
4. При необходимости добавьте переиспользуемые компоненты/хуки в `src/shared`.
Следование существующим паттернам гарантирует единообразие и упрощает поддержку сервиса.

387
docs/part1-2-brief.md Normal file
View File

@@ -0,0 +1,387 @@
# 📘 Бриф по Главе 1
## Тема: Рекламно-информационный сайт предприятия
---
## 🎯 Цель главы
Определить значение рекламно-информационного сайта в деятельности организации, проанализировать предметную область и существующие решения, сформулировать требования к создаваемой системе и обосновать выбор инструментов и технологий для её реализации.
---
## 🧩 Структура и содержание
### **1.1. Введение в тему**
Рекламно-информационные сайты играют ключевую роль в цифровой деятельности организаций. Они обеспечивают представление компании в сети, выполняют функции информирования, продвижения и коммуникации с клиентами. Основная задача таких систем — создание единого центра актуальной информации, доступного пользователям в круглосуточном режиме.
---
### **1.2. Роль рекламно-информационного сайта в деятельности компании**
* Сайт выступает инструментом маркетингового взаимодействия, каналом распространения информации и формирования имиджа.
* Обеспечивает комплексное продвижение товаров и услуг, снижает зависимость от традиционных каналов рекламы.
* Выполняет функции оперативного обновления информации, аналитики пользовательской активности и обратной связи.
* Повышает уровень доверия к компании, способствует укреплению бренда и конкурентоспособности.
---
### **1.3. Анализ предметной области и существующих решений**
* Предметная область охватывает процессы разработки веб-ресурсов, организации хранения и обработки данных, а также взаимодействие с пользователем.
* Типичные функции сайтов-аналогов: главная страница, разделы «О компании», каталог услуг, новости, формы обратной связи, адаптивная верстка.
* Современные тенденции: использование систем управления контентом (CMS), интеграция с социальными сетями, мультимедийный контент, адаптивный дизайн и персонализированные сервисы.
* Вывод: сайт должен быть информативным, интуитивно понятным и обеспечивать оперативное обновление данных при высокой производительности.
---
### **1.4. Постановка задачи**
#### **Функциональные требования:**
* публикация сведений об организации, услугах и новостях;
* поиск по содержимому сайта;
* формы обратной связи и администрирование контента;
* централизованное хранение данных в базе.
#### **Нефункциональные требования:**
* корректное отображение на различных устройствах;
* безопасность и защита данных;
* высокая скорость работы и возможность масштабирования;
* удобство сопровождения и расширяемость архитектуры.
Задача разработки заключается в создании системы, обеспечивающей удобный доступ к информации и эффективное управление содержимым со стороны организации.
---
### **1.5. Обоснование выбора инструментов и технологий**
| Компонент | Технология | Аргументация выбора |
| --------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
| **Фронтенд** | React | Модульная структура, высокая производительность, повторное использование компонентов. |
| **Бэкенд** | Kotlin (Ktor / Spring Boot) | Строгая типизация, устойчивость, поддержка асинхронных операций, интеграция с Java-экосистемой. |
| **База данных** | PostgreSQL | Реляционная модель, транзакционная целостность, масштабируемость, надёжность. |
| **Веб-сервер** | NGINX | Высокая производительность, стабильность, возможности балансировки нагрузки. |
📌 Совокупность данных технологий обеспечивает создание надёжной, безопасной и масштабируемой системы, соответствующей современным требованиям веб-разработки.
---
## 🧠 Ключевые выводы по главе
1. Рекламно-информационный сайт является стратегически значимым элементом цифрового присутствия организации.
2. Анализ аналогичных решений позволил определить актуальные тенденции и стандарты для разработки.
3. Сформулированные требования отражают как пользовательские, так и внутренние потребности компании.
4. Выбор инструментов (React, Kotlin, PostgreSQL, NGINX) обоснован с позиций эффективности, надёжности и дальнейшего развития проекта.
5. Итогом первой главы является определение концептуальной базы и технологического фундамента для последующего проектирования системы.
---
## 📄 Итоговая роль главы
Первая глава закладывает методологическую и техническую основу дипломного проекта.
На её основе формируется архитектура системы, разрабатывается структура базы данных и описываются механизмы взаимодействия клиентской и серверной частей, которые раскрываются в последующих разделах работы.
Brief: Проектирование и разработка веб-приложения «BankInfo»
(суммаризация второй главы)
1. Архитектурная концепция
Веб-приложение реализовано как трёхуровневая система:
Frontend — React SPA (React 19, TypeScript, MobX, MUI, React Router, Zod)
Backend — Ktor (Kotlin), Ktorm ORM, HikariCP, JWT
Database — PostgreSQL 14 (схема public)
Ключевые принципы проектирования:
модульность и слабая связанность;
разделение ответственности по слоям;
REST-взаимодействие между клиентом и сервером;
* статeless-авторизация через JWT;
* строгая типизация DTO и валидируемых данных.
---
## **2. Архитектура приложения**
### **2.1. Общая схема взаимодействия**
Поток данных:
```
Client (React SPA)
→ REST API /api/v1/*
→ Ktor Server (Routing → Controllers → Services → Repositories)
→ PostgreSQL (SQL через Ktorm)
← JSON Response
```
В приложении предусмотрено место под:
* **Рисунок 2.1** — Architecture / Deployment diagram
* **Приложение А** — увеличенная схема
---
### **2.2. Backend**
Структура серверной части:
```
app/
├─ Database (HikariCP)
├─ HTTP plugins
├─ Serialization (kotlinx)
├─ Security (JWT)
└─ Routing (подключение модулей)
modules/
├─ aНиже — **брив (brief-doc) по всей второй главе**, оформленный в Markdown.
Это краткая, структурированная, сжатая версия всей главы 2 — идеально подходит:
* для презентации,
* для защиты диплома,
* для приложений,
* для рабочего черновика.
Готов к копированию в отдельный файл `brief-chapter-2.md`.
---
# **Brief: Проектирование и разработка веб-приложения «BankInfo»**
*(суммаризация второй главы)*
---
## **1. Архитектурная концепция**
Веб-приложение реализовано как трёхуровневая система:
* **Frontend** — React SPA (React 19, TypeScript, MobX, MUI, React Router, Zod)
* **Backend** — Ktor (Kotlin), Ktorm ORM, HikariCP, JWT
* **Database** — PostgreSQL 14 (схема `public`)
Ключевые принципы проектирования:
* модульность и слабая связанность;
* раздdmin
├─ news
├─ services
├─ serviceCategory
└─ lead
```
Используемые паттерны:
* **Layered Architecture**
* **Controller → Service → Repository**
* **DTO Boundary**
* **Repository Pattern (DDD)**
* **Exception Mapping** через StatusPages
* **Dependency Injection (ручной)**
* **Stateless Auth** через JWT
Основные технические особенности:
* валидация slug/username/email реализована сервисами;
* eager-loading связей в услугах (JOIN в Ktorm);
* SQL-пагинация через `LIMIT/OFFSET`;
* единый формат ошибок;
* отсутствие уникальных ограничений на уровне БД по slug/email (осознанное решение).
---
### **2.3. Frontend**
Стек:
* React 19
* TypeScript
* MobX (`observer`, observable state tree)
* React Router v7 (nested routing)
* MUI (design system)
* Zod (schema validation)
* Vite (bundler)
Структура каталогов:
```
src/
├─ app/ (маршруты, App)
├─ modules/ (публичные страницы + админка)
├─ stores/ (MobX)
├─ api/ (httpClient + SDK + DTO + Zod)
├─ shared/ (UI-компоненты, тема)
├─ providers/ (StoreProvider, ThemeProvider)
└─ assets/
```
Паттерны:
* **Component-based architecture**
* **Separation of concerns**
* **Observer Pattern (MobX)**
* **Gateway/API Client**
* **DTO Boundary для фронтенда**
* **Layout Composition** для админки
* **Design System Architecture** (MUI)
Поток данных:
```
Component → Store → API → Store → UI
```
Вставляется как:
* **Рисунок 2.3** — Data Flow Diagram
* **Приложение В** — полная версия
---
## **3. REST API**
Основная структура: `/api/v1/*`
Ключевые маршруты:
| Endpoint | Метод | Назначение |
| ------------- | ------------------- | -------------------------- |
| `/auth/login` | POST | Авторизация администратора |
| `/news` | GET/POST/PUT/DELETE | Управление новостями |
| `/services` | GET/POST/PUT/DELETE | Услуги |
| `/categories` | GET/POST/PUT/DELETE | Категории |
| `/leads` | GET/POST | Лиды |
| `/users` | GET/POST/PUT/DELETE | Администраторы |
Все административные endpoints защищены JWT-middleware.
---
## **4. Модель данных**
### **Основные сущности:**
* **Admin**
* **News**
* **ServiceCategory**
* **Service**
* **Lead**
### **Особенности БД:**
* PostgreSQL 14.19
* схема: `public`
* `timestamptz` для временных полей
* `varchar` дефолтной длины
* единственный FK: `services.category_id → categories.id`
* уникальность slug/username/email **не** enforced в БД (реализуется сервисами)
### **Реализация через Ktorm:**
* интерфейсы Entity
* объекты Table
* `Database.sequenceOf(Table)`
* сущности загружаются с категориями через JOIN (eager)
* DTO разделены на публичные и административные
ER-диаграмма:
* **Рисунок 2.4** — ER Diagram
* **Приложение Д** — масштабируемая версия
---
## **5. UI / UX проектирование**
Публичные страницы:
* Главная
* Услуги
* Услуга
* Новости
* Новость
* О компании
* Контакты / форма лида
Административная панель:
* Dashboard
* Администраторы
* Услуги
* Категории
* Новости
* Лиды
`sitemap`:
* **диаграмма (Рисунок 2.5)**
* **Приложение Е**
---
## **6. Реализация**
### **Backend:**
* безопасная авторизация JWT
* унифицированные CRUD-модули
* SQL-запросы через Ktorm
* централизованная обработка ошибок
* слабая связанность сущностей
* ручная структура БД (без миграций)
### **Frontend:**
* реактивность MobX
* строгая типизация API через Zod
* модульная структура pages/features/components
* единая тема оформления
* локальная валидация форм
* защищённые маршруты для админки
---
## **7. Тестирование**
### **Проверялось:**
* корректность REST API
* авторизация и работа с JWT
* маршруты и вложенная навигация
* формы и валидация
* реактивность MobX
* стабильность CRUD-операций
* отображение данных на публичных страницах
* стабильность админ-панели
---
## **8. Общий вывод**
Система имеет следующую совокупность свойств:
* надёжная архитектурная основа;
* модульность и расширяемость;
* современный стек технологий;
* строгие границы ответственности между слоями;
* безопасная модель аутентификации;
* предсказуемые, универсальные CRUD-процессы;
* реактивность клиентской части;
* типобезопасный сетевой слой;
* минимальная связанность базы данных;
* удобная административная панель;
* чистая архитектура backend и frontend.
С точки зрения инженерии ПО, проект демонстрирует высокий уровень проработки, следование современным практикам и архитектурным принципам, а также готовность к дальнейшему развитию и эксплуатационному использованию

133
docs/sitemap.svg Normal file
View File

@@ -0,0 +1,133 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1450" height="520" viewBox="0 0 1450 520">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7"
orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#444"/>
</marker>
<style>
text { font-family: sans-serif; fill: #222; font-size: 14px; }
.node { fill: #eef3fa; stroke: #444; stroke-width: 1.5; rx: 6; ry: 6; }
.section-label { font-weight: 600; font-size: 16px; }
.group-box { fill: none; stroke: #bbb; stroke-width: 1; stroke-dasharray: 4 4; }
</style>
</defs>
<rect width="1450" height="520" fill="#ffffff"/>
<text x="725" y="40" text-anchor="middle" font-size="20">Карта сайта веб-приложения</text>
<!-- Public group -->
<rect class="group-box" x="60" y="80" width="820" height="390"/>
<text class="section-label" x="70" y="100">Публичный раздел</text>
<!-- Root -->
<rect class="node" x="100" y="190" width="190" height="80"/>
<text x="195" y="215" text-anchor="middle">
<tspan x="195" y="215">/</tspan>
<tspan x="195" y="235">Главная</tspan>
<tspan x="195" y="253">(основная)</tspan>
</text>
<!-- Public nodes -->
<rect class="node" x="360" y="110" width="210" height="70"/>
<text x="465" y="135" text-anchor="middle">
<tspan x="465" y="135">/services</tspan>
<tspan x="465" y="155">Список услуг</tspan>
</text>
<rect class="node" x="360" y="200" width="210" height="70"/>
<text x="465" y="225" text-anchor="middle">
<tspan x="465" y="225">/news</tspan>
<tspan x="465" y="245">Лента новостей</tspan>
</text>
<rect class="node" x="360" y="290" width="210" height="70"/>
<text x="465" y="315" text-anchor="middle">
<tspan x="465" y="315">/about</tspan>
<tspan x="465" y="335">О компании</tspan>
</text>
<rect class="node" x="360" y="380" width="210" height="70"/>
<text x="465" y="405" text-anchor="middle">
<tspan x="465" y="405">/contacts</tspan>
<tspan x="465" y="425">Контакты / заявка</tspan>
</text>
<!-- Detail nodes -->
<rect class="node" x="620" y="90" width="210" height="70"/>
<text x="725" y="115" text-anchor="middle">
<tspan x="725" y="115">/services/:slug</tspan>
<tspan x="725" y="135">Детали услуги</tspan>
</text>
<rect class="node" x="620" y="180" width="210" height="70"/>
<text x="725" y="205" text-anchor="middle">
<tspan x="725" y="205">/news/:slug</tspan>
<tspan x="725" y="225">Новостная статья</tspan>
</text>
<!-- Public connectors -->
<path d="M290 230 L330 230 L330 145 L360 145" stroke="#444" stroke-width="1.5" fill="none"
marker-end="url(#arrow)"/>
<path d="M290 230 L330 230 L330 230 L360 230" stroke="#444" stroke-width="1.5" fill="none"
marker-end="url(#arrow)"/>
<path d="M290 230 L330 230 L330 325 L360 325" stroke="#444" stroke-width="1.5" fill="none"
marker-end="url(#arrow)"/>
<path d="M290 230 L330 230 L330 415 L360 415" stroke="#444" stroke-width="1.5" fill="none"
marker-end="url(#arrow)"/>
<line x1="570" y1="145" x2="620" y2="125" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="570" y1="235" x2="620" y2="215" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Admin group -->
<rect class="group-box" x="900" y="80" width="520" height="430"/>
<text class="section-label" x="910" y="100">Админ-раздел</text>
<rect class="node" x="930" y="130" width="220" height="70"/>
<text x="1040" y="155" text-anchor="middle">
<tspan x="1040" y="155">/admin</tspan>
<tspan x="1040" y="175">Логин</tspan>
</text>
<rect class="node" x="930" y="220" width="220" height="70"/>
<text x="1040" y="245" text-anchor="middle">
<tspan x="1040" y="245">/admin/dashboard</tspan>
<tspan x="1040" y="265">Панель управления</tspan>
</text>
<rect class="node" x="930" y="310" width="220" height="55"/>
<text x="1040" y="335" text-anchor="middle">
<tspan x="1040" y="335">/admin/dashboard/news</tspan>
<tspan x="1040" y="353">Управление новостями</tspan>
</text>
<rect class="node" x="930" y="375" width="220" height="55"/>
<text x="1040" y="400" text-anchor="middle">
<tspan x="1040" y="400">/admin/dashboard/services</tspan>
<tspan x="1040" y="418">Управление услугами</tspan>
</text>
<rect class="node" x="930" y="440" width="220" height="55"/>
<text x="1040" y="465" text-anchor="middle">
<tspan x="1040" y="465">/admin/dashboard/service-categories</tspan>
<tspan x="1040" y="483">Категории услуг</tspan>
</text>
<rect class="node" x="1180" y="310" width="220" height="55"/>
<text x="1290" y="335" text-anchor="middle">
<tspan x="1290" y="335">/admin/dashboard/leads</tspan>
<tspan x="1290" y="353">Заявки</tspan>
</text>
<rect class="node" x="1180" y="375" width="220" height="55"/>
<text x="1290" y="400" text-anchor="middle">
<tspan x="1290" y="400">/admin/dashboard/users</tspan>
<tspan x="1290" y="418">Администраторы</tspan>
</text>
<!-- Admin connectors -->
<line x1="1040" y1="200" x2="1040" y2="220" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="1040" y1="290" x2="1040" y2="310" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="1040" y1="365" x2="1040" y2="375" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="1040" y1="430" x2="1040" y2="440" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="1150" y1="338" x2="1180" y2="338" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="1150" y1="403" x2="1180" y2="403" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

57
docs/structure.svg Normal file
View File

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="320" viewBox="0 0 900 320">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8"
orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#4a6179"/>
</marker>
<style>
text { font-family: monospace; fill: #222; font-size: 14px; }
.node { fill: #e8eef5; stroke: #4a6179; stroke-width: 1.5; }
.label { font-size: 13px; }
</style>
</defs>
<rect width="900" height="320" fill="#ffffff"/>
<text x="450" y="40" font-size="18" text-anchor="middle">Поток данных в клиентском приложении (Компонент → Store →
API → Store → UI)
</text>
<rect class="node" x="30" y="90" width="150" height="80" rx="8" ry="8"/>
<text class="label" x="105" y="120" text-anchor="middle">
<tspan x="105" y="120">Компонент React</tspan>
<tspan x="105" y="140">(UI + обработчики)</tspan>
</text>
<rect class="node" x="210" y="90" width="150" height="80" rx="8" ry="8"/>
<text class="label" x="285" y="120" text-anchor="middle">
<tspan x="285" y="120">MobX Store</tspan>
<tspan x="285" y="140">(управление</tspan>
<tspan x="285" y="156">состоянием)</tspan>
</text>
<rect class="node" x="390" y="90" width="150" height="80" rx="8" ry="8"/>
<text class="label" x="465" y="120" text-anchor="middle">
<tspan x="465" y="120">API Client</tspan>
<tspan x="465" y="140">(fetch + Zod</tspan>
<tspan x="465" y="156">валидация)</tspan>
</text>
<rect class="node" x="570" y="90" width="150" height="80" rx="8" ry="8"/>
<text class="label" x="645" y="120" text-anchor="middle">
<tspan x="645" y="120">Store Update</tspan>
<tspan x="645" y="140">(наблюдаемые</tspan>
<tspan x="645" y="156">изменения)</tspan>
</text>
<rect class="node" x="750" y="90" width="150" height="80" rx="8" ry="8"/>
<text class="label" x="825" y="120" text-anchor="middle">
<tspan x="825" y="120">UI Re-render</tspan>
<tspan x="825" y="140">(observer</tspan>
<tspan x="825" y="156">компоненты)</tspan>
</text>
<line x1="180" y1="130" x2="210" y2="130" stroke="#4a6179" stroke-width="2" marker-end="url(#arrow)"/>
<text x="195" y="75" text-anchor="middle">действие / намерение</text>
<line x1="360" y1="130" x2="390" y2="130" stroke="#4a6179" stroke-width="2" marker-end="url(#arrow)"/>
<text x="375" y="75" text-anchor="middle">fetch-запрос</text>
<line x1="540" y1="130" x2="570" y2="130" stroke="#4a6179" stroke-width="2" marker-end="url(#arrow)"/>
<text x="555" y="75" text-anchor="middle">валидированный ответ</text>
<line x1="720" y1="130" x2="750" y2="130" stroke="#4a6179" stroke-width="2" marker-end="url(#arrow)"/>
<text x="735" y="75" text-anchor="middle">событие изменения</text>
<path d="M780 170 C 700 250, 220 250, 150 170" fill="none" stroke="#4a6179" stroke-width="2"
marker-end="url(#arrow)"/>
<text x="465" y="250" text-anchor="middle">следующее взаимодействие</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>

14
nginx.conf Normal file
View File

@@ -0,0 +1,14 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public";
}
}

1021
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"autoprefixer": "^10.4.21",
"mobx": "^6.13.4",
"mobx-react-lite": "^4.0.7",
"postcss-nested": "^7.0.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
@@ -23,8 +32,11 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"prettier": "3.6.2",
"react-router-dom": "^7.9.4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
"vite": "^7.1.7",
"zod": "^4.1.12"
}
}

14
postcss.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('postcss-load-config').Config} */
import prefix from 'autoprefixer'
import nest from 'postcss-nested'
const config = {
plugins: [
prefix,
nest
]
}
export default config

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

175
src/api/admin/index.ts Normal file
View File

@@ -0,0 +1,175 @@
import { del, post, put, request, setAdminAuthToken } from '../httpClient.ts';
import {
adminLoginPayloadSchema,
adminLoginResponseSchema,
adminProfileSchema,
adminServiceCategoryListSchema,
adminServiceCategoryCreatePayloadSchema,
adminServiceCategoryCreateResponseSchema,
adminServiceCategoryUpdatePayloadSchema,
adminServiceCategoryUpdateResponseSchema,
adminNewsUpdatePayloadSchema,
adminNewsUpdateResponseSchema,
adminNewsCreatePayloadSchema,
adminNewsCreateResponseSchema,
adminRegisterPayloadSchema,
adminRegisterResponseSchema,
adminPasswordChangePayloadSchema,
adminPasswordChangeResponseSchema,
adminDeleteResponseSchema,
adminServiceCreatePayloadSchema,
adminServiceCreateResponseSchema,
adminServiceUpdatePayloadSchema,
adminServiceUpdateResponseSchema,
type AdminLoginPayload,
type AdminLoginResponse,
type AdminProfile,
type AdminServiceCategoryList,
type AdminServiceCategoryCreatePayload,
type AdminServiceCategoryCreateResponse,
type AdminServiceCategoryUpdatePayload,
type AdminServiceCategoryUpdateResponse,
type AdminNewsUpdatePayload,
type AdminNewsUpdateResponse,
type AdminNewsCreatePayload,
type AdminNewsCreateResponse,
type AdminRegisterPayload,
type AdminRegisterResponse,
type AdminPasswordChangePayload,
type AdminPasswordChangeResponse,
type AdminDeleteResponse,
type AdminServiceCreatePayload,
type AdminServiceCreateResponse,
type AdminServiceUpdatePayload,
type AdminServiceUpdateResponse,
} from './types.ts';
import { type NewsPageResponse, newsPageSchema } from '../news/types.ts';
import type { ListNewsParams } from '../news';
import { serviceItemSchema, servicePageSchema } from '../services/types.ts';
import type { ServiceItem } from '../services/types.ts';
import type { ListServicesParams } from '../services';
const ADMIN_LOGIN_ENDPOINT = '/api/v1/admin/login';
const ADMIN_PROFILE_ENDPOINT = '/api/v1/admin';
const ADMIN_SERVICE_CATEGORIES_ENDPOINT = '/api/v1/admin/service-categories';
const ADMIN_SERVICES_ENDPOINT = '/api/v1/admin/services';
const ADMIN_NEWS_ENDPOINT = '/api/v1/admin/news';
const buildNewsQuery = (params?: ListNewsParams) => {
if (!params) {
return undefined;
}
const { page, limit, search, tags } = params;
return { page, limit, search, tags };
};
const buildServicesQuery = (params?: ListServicesParams) => {
if (!params) {
return undefined;
}
const { page, limit, q, category, minPrice, maxPrice } = params;
return { page, limit, q, category, minPrice, maxPrice };
};
export const adminApi = {
async login(payload: AdminLoginPayload): Promise<AdminLoginResponse> {
adminLoginPayloadSchema.parse(payload);
const response = await post(ADMIN_LOGIN_ENDPOINT, adminLoginResponseSchema, {
body: payload,
});
setAdminAuthToken(response.token);
return response;
},
async profile(): Promise<AdminProfile> {
return request(ADMIN_PROFILE_ENDPOINT, adminProfileSchema);
},
async registerAdmin(payload: AdminRegisterPayload): Promise<AdminRegisterResponse> {
adminRegisterPayloadSchema.parse(payload);
return post(ADMIN_PROFILE_ENDPOINT, adminRegisterResponseSchema, {
body: payload,
});
},
async changePassword(adminId: string | number, payload: AdminPasswordChangePayload): Promise<AdminPasswordChangeResponse> {
adminPasswordChangePayloadSchema.parse(payload);
return put(`${ADMIN_PROFILE_ENDPOINT}/${adminId}/password`, adminPasswordChangeResponseSchema, {
body: payload,
});
},
async deleteAdmin(adminId: string | number): Promise<AdminDeleteResponse> {
return del(`${ADMIN_PROFILE_ENDPOINT}/${adminId}`, adminDeleteResponseSchema);
},
async listServiceCategories(): Promise<AdminServiceCategoryList> {
return request(ADMIN_SERVICE_CATEGORIES_ENDPOINT, adminServiceCategoryListSchema);
},
async createServiceCategory(payload: AdminServiceCategoryCreatePayload): Promise<AdminServiceCategoryCreateResponse> {
const normalized = adminServiceCategoryCreatePayloadSchema.parse(payload);
return post(ADMIN_SERVICE_CATEGORIES_ENDPOINT, adminServiceCategoryCreateResponseSchema, {
body: normalized,
});
},
async updateServiceCategory(
identifier: string | number,
payload: AdminServiceCategoryUpdatePayload,
): Promise<AdminServiceCategoryUpdateResponse> {
const normalized = adminServiceCategoryUpdatePayloadSchema.parse(payload);
return put(`${ADMIN_SERVICE_CATEGORIES_ENDPOINT}/${identifier}`, adminServiceCategoryUpdateResponseSchema, {
body: normalized,
});
},
async listAdminServices(params?: ListServicesParams) {
return request(ADMIN_SERVICES_ENDPOINT, servicePageSchema, {
query: buildServicesQuery(params),
});
},
async createService(payload: AdminServiceCreatePayload): Promise<AdminServiceCreateResponse> {
const normalized = adminServiceCreatePayloadSchema.parse(payload);
return post(ADMIN_SERVICES_ENDPOINT, adminServiceCreateResponseSchema, {
body: normalized,
});
},
async updateService(identifier: string | number, payload: AdminServiceUpdatePayload): Promise<AdminServiceUpdateResponse> {
const normalized = adminServiceUpdatePayloadSchema.parse(payload);
return put(`${ADMIN_SERVICES_ENDPOINT}/${identifier}`, adminServiceUpdateResponseSchema, {
body: normalized,
});
},
async getAdminService(identifier: string | number): Promise<ServiceItem> {
return request(`${ADMIN_SERVICES_ENDPOINT}/${identifier}`, serviceItemSchema);
},
async listNews(params?: ListNewsParams): Promise<NewsPageResponse> {
return request(ADMIN_NEWS_ENDPOINT, newsPageSchema, {
query: buildNewsQuery(params),
});
},
async createNews(payload: AdminNewsCreatePayload): Promise<AdminNewsCreateResponse> {
adminNewsCreatePayloadSchema.parse(payload);
return post(ADMIN_NEWS_ENDPOINT, adminNewsCreateResponseSchema, {
body: payload,
});
},
async updateNews(identifier: string, payload: AdminNewsUpdatePayload): Promise<AdminNewsUpdateResponse> {
adminNewsUpdatePayloadSchema.parse(payload);
return put(`${ADMIN_NEWS_ENDPOINT}/${identifier}`, adminNewsUpdateResponseSchema, {
body: payload,
});
},
logout() {
setAdminAuthToken(null);
},
};

181
src/api/admin/types.ts Normal file
View File

@@ -0,0 +1,181 @@
import { z } from 'zod';
export const adminLoginPayloadSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type AdminLoginPayload = z.infer<typeof adminLoginPayloadSchema>;
export const adminLoginResponseSchema = z.object({
token: z.string(),
});
export type AdminLoginResponse = z.infer<typeof adminLoginResponseSchema>;
export const adminProfileSchema = z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
createdAt: z.string(),
});
export type AdminProfile = z.infer<typeof adminProfileSchema>;
export const adminRegisterPayloadSchema = z.object({
username: z.string().min(3),
password: z.string().min(8),
});
export type AdminRegisterPayload = z.infer<typeof adminRegisterPayloadSchema>;
export const adminRegisterResponseSchema = z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
});
export type AdminRegisterResponse = z.infer<typeof adminRegisterResponseSchema>;
export const adminPasswordChangePayloadSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
});
export type AdminPasswordChangePayload = z.infer<typeof adminPasswordChangePayloadSchema>;
export const adminPasswordChangeResponseSchema = z.object({
updated: z.boolean(),
});
export type AdminPasswordChangeResponse = z.infer<typeof adminPasswordChangeResponseSchema>;
export const adminDeleteResponseSchema = z.object({
deleted: z.boolean(),
});
export type AdminDeleteResponse = z.infer<typeof adminDeleteResponseSchema>;
const optionalString = z
.string()
.trim()
.transform((value) => (value.length === 0 ? undefined : value))
.optional();
const optionalNullableString = optionalString.nullable();
const optionalNullableNumberId = z
.union([z.string(), z.number()])
.transform((value) => {
const numeric = Number(value);
return Number.isNaN(numeric) ? undefined : numeric;
})
.optional()
.nullable();
export const adminServiceCreatePayloadSchema = z.object({
title: z.string().min(1),
slug: z.string().min(1),
description: z.string().min(1),
priceFrom: optionalNullableString,
imageUrl: optionalNullableString,
status: optionalString,
categoryId: optionalNullableNumberId,
});
export type AdminServiceCreatePayload = z.infer<typeof adminServiceCreatePayloadSchema>;
export const adminServiceCreateResponseSchema = z.object({
id: z.union([z.string(), z.number()]),
});
export type AdminServiceCreateResponse = z.infer<typeof adminServiceCreateResponseSchema>;
export const adminServiceUpdatePayloadSchema = z.object({
title: z.string().min(1).optional(),
slug: z.string().min(1).optional(),
description: z.string().min(1).optional(),
priceFrom: optionalNullableString,
imageUrl: optionalNullableString,
status: optionalString,
categoryId: optionalNullableNumberId,
});
export type AdminServiceUpdatePayload = z.infer<typeof adminServiceUpdatePayloadSchema>;
export const adminServiceUpdateResponseSchema = z.object({
updated: z.boolean(),
});
export type AdminServiceUpdateResponse = z.infer<typeof adminServiceUpdateResponseSchema>;
export const adminServiceCategorySchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
slug: z.string(),
});
export type AdminServiceCategory = z.infer<typeof adminServiceCategorySchema>;
export const adminServiceCategoryListSchema = z.array(adminServiceCategorySchema);
export type AdminServiceCategoryList = z.infer<typeof adminServiceCategoryListSchema>;
export const adminServiceCategoryCreatePayloadSchema = z.object({
name: z.string().min(1),
slug: z.string().min(1),
});
export type AdminServiceCategoryCreatePayload = z.infer<typeof adminServiceCategoryCreatePayloadSchema>;
export const adminServiceCategoryCreateResponseSchema = z.object({
id: z.union([z.string(), z.number()]),
});
export type AdminServiceCategoryCreateResponse = z.infer<typeof adminServiceCategoryCreateResponseSchema>;
export const adminServiceCategoryUpdatePayloadSchema = z.object({
name: z.string().min(1).optional(),
slug: z.string().min(1).optional(),
});
export type AdminServiceCategoryUpdatePayload = z.infer<typeof adminServiceCategoryUpdatePayloadSchema>;
export const adminServiceCategoryUpdateResponseSchema = z.object({
updated: z.boolean(),
});
export type AdminServiceCategoryUpdateResponse = z.infer<typeof adminServiceCategoryUpdateResponseSchema>;
export const adminNewsUpdatePayloadSchema = z.object({
title: z.string().min(1),
slug: z.string().min(1),
summary: z.string().min(1),
content: z.string().min(1),
imageUrl: z.string().url().optional().or(z.null()),
status: z.enum(['draft', 'published', 'archived']),
});
export type AdminNewsUpdatePayload = z.infer<typeof adminNewsUpdatePayloadSchema>;
export const adminNewsCreateResponseSchema = z.object({
id: z.union([z.string(), z.number()]),
});
export type AdminNewsCreateResponse = z.infer<typeof adminNewsCreateResponseSchema>;
export const adminNewsCreatePayloadSchema = adminNewsUpdatePayloadSchema.extend({
status: adminNewsUpdatePayloadSchema.shape.status.default('draft'),
});
export type AdminNewsCreatePayload = z.infer<typeof adminNewsCreatePayloadSchema>;
export const adminNewsPublishResponseSchema = z.object({
published: z.boolean(),
});
export type AdminNewsPublishResponse = z.infer<typeof adminNewsPublishResponseSchema>;
export const adminNewsUpdateResponseSchema = z.object({
updated: z.boolean(),
});
export type AdminNewsUpdateResponse = z.infer<typeof adminNewsUpdateResponseSchema>;

199
src/api/httpClient.ts Normal file
View File

@@ -0,0 +1,199 @@
import { z } from 'zod';
type QueryValue = string | number | boolean | undefined | null;
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export type RequestQuery = Record<string, QueryValue | QueryValue[] | undefined>;
export interface HttpRequestOptions extends Omit<RequestInit, 'body' | 'method'> {
query?: RequestQuery;
body?: Record<string, unknown> | FormData;
}
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080';
const ADMIN_TOKEN_STORAGE_KEY = 'admin_token';
const ADMIN_API_PREFIX = '/api/v1/admin';
const isBrowserEnvironment = typeof window !== 'undefined';
let adminAuthToken: string | null = null;
const loadAdminTokenFromStorage = () => {
if (!isBrowserEnvironment) {
return null;
}
const storedToken = window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
adminAuthToken = storedToken;
return storedToken;
};
loadAdminTokenFromStorage();
export const setAdminAuthToken = (token: string | null) => {
adminAuthToken = token;
if (!isBrowserEnvironment) {
return;
}
if (token) {
window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
} else {
window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
}
};
export const getAdminAuthToken = () => adminAuthToken;
export class ApiError extends Error {
status: number;
details?: unknown;
constructor(message: string, status: number, details?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.details = details;
}
}
const buildQueryString = (query?: RequestQuery) => {
if (!query) {
return '';
}
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((item) => {
if (item === undefined || item === null) {
return;
}
params.append(key, String(item));
});
return;
}
params.append(key, String(value));
});
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
};
const buildUrl = (path: string, query?: RequestQuery) => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}${buildQueryString(query)}`;
};
const shouldAttachAdminAuth = (path: string) => path.startsWith(ADMIN_API_PREFIX);
const buildHeaders = (base: Record<string, string>, extra?: HeadersInit) => {
const headers = new Headers(base);
if (extra) {
const additional = new Headers(extra);
additional.forEach((value, key) => {
headers.set(key, value);
});
}
return headers;
};
const send = async <T>(
method: HttpMethod,
path: string,
schema: z.ZodType<T>,
options?: HttpRequestOptions,
): Promise<T> => {
const { query, body, headers, ...rest } = options ?? {};
const url = buildUrl(path, query);
const hasJsonBody = body && !(body instanceof FormData);
const shouldSendBody = method !== 'GET' && method !== 'DELETE' ? true : Boolean(body);
const baseHeaders: Record<string, string> = {
Accept: 'application/json',
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
};
const requestHeaders = buildHeaders(baseHeaders, headers);
if (shouldAttachAdminAuth(path) && adminAuthToken) {
requestHeaders.set('Authorization', `Bearer ${adminAuthToken}`);
}
const response = await fetch(url, {
method,
...rest,
headers: requestHeaders,
body: shouldSendBody ? (hasJsonBody ? JSON.stringify(body) : (body ?? null)) : undefined,
});
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const isNoContentStatus = response.status === 204 || response.status === 205;
const payloadHasBody = !isNoContentStatus && contentLength !== '0';
let payload: unknown = undefined;
if (payloadHasBody) {
const rawBody = await response.text();
if (rawBody.length > 0) {
if (contentType && contentType.includes('application/json')) {
try {
payload = JSON.parse(rawBody);
} catch {
payload = rawBody;
}
} else {
payload = rawBody;
}
}
}
if (!response.ok) {
const serverMessage =
typeof payload === 'object' && payload !== null && 'error' in payload && typeof (payload as Record<string, unknown>).error === 'string'
? (payload as { error: string }).error
: null;
if (response.status === 401) {
setAdminAuthToken(null);
if (isBrowserEnvironment) {
window.location.replace('/admin');
}
}
throw new ApiError(serverMessage ?? 'Request failed', response.status, payload);
}
return schema.parse(payload);
};
export const request = async <T>(
path: string,
schema: z.ZodType<T>,
options?: HttpRequestOptions,
) => send('GET', path, schema, options);
export const post = async <T>(
path: string,
schema: z.ZodType<T>,
options?: HttpRequestOptions,
) => send('POST', path, schema, options);
export const put = async <T>(
path: string,
schema: z.ZodType<T>,
options?: HttpRequestOptions,
) => send('PUT', path, schema, options);
export const del = async <T>(
path: string,
schema: z.ZodType<T>,
options?: HttpRequestOptions,
) => send('DELETE', path, schema, options);

39
src/api/index.ts Normal file
View File

@@ -0,0 +1,39 @@
export { newsApi } from './news/index.ts';
export type { NewsItem, NewsListResponse } from './news/types.ts';
export { servicesApi } from './services/index.ts';
export type { ServiceCategory, ServiceItem, ServiceListResponse } from './services/types.ts';
export { adminApi } from './admin/index.ts';
export type {
AdminLoginPayload,
AdminLoginResponse,
AdminProfile,
AdminServiceCategory,
AdminServiceCategoryList,
AdminServiceCategoryCreatePayload,
AdminServiceCategoryCreateResponse,
AdminServiceCategoryUpdatePayload,
AdminServiceCategoryUpdateResponse,
AdminRegisterPayload,
AdminRegisterResponse,
AdminPasswordChangePayload,
AdminPasswordChangeResponse,
AdminDeleteResponse,
AdminServiceCreatePayload,
AdminServiceCreateResponse,
AdminServiceUpdatePayload,
AdminServiceUpdateResponse,
AdminNewsUpdatePayload,
AdminNewsCreateResponse,
AdminNewsCreatePayload,
AdminNewsPublishResponse,
AdminNewsUpdateResponse,
} from './admin/types.ts';
export { leadsApi } from './leads/index.ts';
export type {
Lead,
LeadCreatePayload,
LeadCreateResponse,
LeadDeleteResponse,
LeadListItem,
LeadPageResponse,
} from './leads/types.ts';

54
src/api/leads/index.ts Normal file
View File

@@ -0,0 +1,54 @@
import { del, post, request } from '../httpClient.ts';
import {
leadCreatePayloadSchema,
leadCreateResponseSchema,
leadDeleteResponseSchema,
leadPageSchema,
leadSchema,
type Lead,
type LeadCreatePayload,
type LeadCreateResponse,
type LeadDeleteResponse,
type LeadPageResponse,
} from './types.ts';
export interface ListLeadsParams {
limit?: number;
page?: number;
q?: string;
}
const PUBLIC_LEADS_ENDPOINT = '/api/v1/leads';
const ADMIN_LEADS_ENDPOINT = '/api/v1/admin/leads';
const buildListQuery = (params?: ListLeadsParams) => {
if (!params) {
return undefined;
}
const { limit, page, q } = params;
return { limit, page, q };
};
export const leadsApi = {
async create(payload: LeadCreatePayload): Promise<LeadCreateResponse> {
const normalized = leadCreatePayloadSchema.parse(payload);
return post(PUBLIC_LEADS_ENDPOINT, leadCreateResponseSchema, {
body: normalized,
});
},
async list(params?: ListLeadsParams): Promise<LeadPageResponse> {
return request(ADMIN_LEADS_ENDPOINT, leadPageSchema, {
query: buildListQuery(params),
});
},
async get(id: number): Promise<Lead> {
return request(`${ADMIN_LEADS_ENDPOINT}/${id}`, leadSchema);
},
async remove(id: number): Promise<LeadDeleteResponse> {
return del(`${ADMIN_LEADS_ENDPOINT}/${id}`, leadDeleteResponseSchema);
},
};

46
src/api/leads/types.ts Normal file
View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
export const leadListItemSchema = z.object({
id: z.number(),
fullName: z.string(),
email: z.string().email(),
phone: z.string().nullable().optional(),
createdAt: z.string().optional(),
});
export type LeadListItem = z.infer<typeof leadListItemSchema>;
export const leadSchema = leadListItemSchema.extend({
createdAt: z.string(),
});
export type Lead = z.infer<typeof leadSchema>;
export const leadPageSchema = z.object({
items: z.array(leadListItemSchema),
total: z.number(),
limit: z.number(),
offset: z.number(),
});
export type LeadPageResponse = z.infer<typeof leadPageSchema>;
export const leadCreatePayloadSchema = z.object({
fullName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(5).max(32).optional(),
});
export type LeadCreatePayload = z.infer<typeof leadCreatePayloadSchema>;
export const leadCreateResponseSchema = z.object({
id: z.number(),
});
export type LeadCreateResponse = z.infer<typeof leadCreateResponseSchema>;
export const leadDeleteResponseSchema = z.object({
deleted: z.boolean(),
});
export type LeadDeleteResponse = z.infer<typeof leadDeleteResponseSchema>;

36
src/api/news/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import { request } from '../httpClient.ts';
import { newsItemSchema, newsPageSchema, type NewsItem, type NewsPageResponse } from './types.ts';
export interface ListNewsParams {
page?: number;
limit?: number;
search?: string;
tags?: string[];
}
const NEWS_ENDPOINT = '/api/v1/news';
const buildQuery = (params?: ListNewsParams) => {
if (!params) {
return undefined;
}
const { page, limit, search, tags } = params;
return {
page,
limit,
search,
tags,
};
};
export const newsApi = {
async list(params?: ListNewsParams): Promise<NewsPageResponse> {
return request(NEWS_ENDPOINT, newsPageSchema, {
query: buildQuery(params),
});
},
async get(slug: string): Promise<NewsItem> {
return request(`${NEWS_ENDPOINT}/${slug}`, newsItemSchema);
},
};

27
src/api/news/types.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export const newsItemSchema = z.object({
id: z.union([z.string(), z.number()]),
title: z.string(),
slug: z.string(),
summary: z.string(),
content: z.string(),
imageUrl: z.string().url().optional().nullable(),
publishedAt: z.string().optional().nullable(),
status: z.enum(['draft', 'published', 'archived']).default('draft'),
});
export type NewsItem = z.infer<typeof newsItemSchema>;
export const newsListSchema = z.array(newsItemSchema);
export type NewsListResponse = z.infer<typeof newsListSchema>;
export const newsPageSchema = z.object({
items: newsListSchema,
total: z.number(),
limit: z.number(),
offset: z.number(),
});
export type NewsPageResponse = z.infer<typeof newsPageSchema>;

41
src/api/services/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import { request } from '../httpClient.ts';
import { serviceItemSchema, servicePageSchema, type ServiceItem, type ServicePageResponse } from './types.ts';
export interface ListServicesParams {
limit?: number;
page?: number;
q?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
}
const SERVICES_ENDPOINT = '/api/v1/services';
const buildQuery = (params?: ListServicesParams) => {
if (!params) {
return undefined;
}
const { limit, page, q, category, minPrice, maxPrice } = params;
return {
limit,
page,
q,
category,
minPrice,
maxPrice,
};
};
export const servicesApi = {
async list(params?: ListServicesParams): Promise<ServicePageResponse> {
return request(SERVICES_ENDPOINT, servicePageSchema, {
query: buildQuery(params),
});
},
async get(slug: string | number): Promise<ServiceItem> {
return request(`${SERVICES_ENDPOINT}/${slug}`, serviceItemSchema);
},
};

39
src/api/services/types.ts Normal file
View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
export const serviceCategorySchema = z
.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
slug: z.string(),
description: z.string().optional(),
})
.passthrough();
export const serviceItemSchema = z.object({
id: z.union([z.string(), z.number()]),
title: z.string(),
slug: z.string(),
description: z.string(),
priceFrom: z.number().nullable().optional(),
imageUrl: z.string().nullable().optional(),
status: z.string(),
category: serviceCategorySchema.nullish(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type ServiceItem = z.infer<typeof serviceItemSchema>;
export type ServiceCategory = z.infer<typeof serviceCategorySchema>;
export const serviceListSchema = z.array(serviceItemSchema);
export type ServiceListResponse = z.infer<typeof serviceListSchema>;
export const servicePageSchema = z.object({
items: serviceListSchema,
total: z.number(),
limit: z.number(),
offset: z.number(),
});
export type ServicePageResponse = z.infer<typeof servicePageSchema>;

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,35 +1,19 @@
import { useState } from 'react'
import reactLogo from '../assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { RouterProvider } from 'react-router';
import './App.css';
import { router } from './router/routes.tsx';
import { StoreProvider } from '../providers/store.tsx';
import CssBaseline from '@mui/material/CssBaseline';
import { AppTheme } from '../shared/theme/theme.tsx';
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
<StoreProvider>
<AppTheme>
<CssBaseline enableColorScheme />
<RouterProvider router={router} />
</AppTheme>
</StoreProvider>
);
}
export default App
export default App;

67
src/app/router/routes.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { MainPage } from '../../modules/main/pages';
import { NewsDetailsPage, NewsFeedPage } from '../../modules/news/pages';
import { ServiceDetailsPage, ServicesListPage } from '../../modules/services/pages';
import { AdminLoginPage } from '../../modules/admin/pages/login.tsx';
import { AdminDashboardPage } from '../../modules/admin/pages/dashboard.tsx';
import { AdminNewsEditPage } from '../../modules/admin/pages/news-edit.tsx';
import { AdminNewsCreatePage } from '../../modules/admin/pages/news-create.tsx';
import { AdminDashboardNews } from '../../modules/admin/components/news/dashboard-news.tsx';
import { AdminDashboardServices } from '../../modules/admin/components/services/dashboard-services.tsx';
import { AdminDashboardLeads } from '../../modules/admin/components/leads/dashboard-leads.tsx';
import { AdminDashboardAdmins } from '../../modules/admin/components/admins/dashboard-admins.tsx';
import { AdminDashboardServiceCategories } from '../../modules/admin/components/service-categories/dashboard-service-categories.tsx';
import { AdminServiceCreatePage } from '../../modules/admin/pages/service-create.tsx';
import { AdminServiceEditPage } from '../../modules/admin/pages/service-edit.tsx';
import { AdminServiceCategoryCreatePage } from '../../modules/admin/pages/service-category-create.tsx';
import { AdminServiceCategoryEditPage } from '../../modules/admin/pages/service-category-edit.tsx';
export const router = createBrowserRouter([
{
path: '/',
element: <MainPage />,
},
{
path: '/news',
element: <NewsFeedPage />,
},
{
path: '/news/:slug',
element: <NewsDetailsPage />,
},
{
path: '/about',
element: <AboutPage />,
},
{
path: '/services',
element: <ServicesListPage />,
},
{
path: '/services/:slug',
element: <ServiceDetailsPage />,
},
{
path: '/admin',
element: <AdminLoginPage />,
},
{
path: '/admin/dashboard',
element: <AdminDashboardPage />,
children: [
{ index: true, element: <Navigate to="news" replace /> },
{ path: 'news/new', element: <AdminNewsCreatePage /> },
{ path: 'news', element: <AdminDashboardNews /> },
{ path: 'news/:slug/edit', element: <AdminNewsEditPage /> },
{ path: 'services/new', element: <AdminServiceCreatePage /> },
{ path: 'services', element: <AdminDashboardServices /> },
{ path: 'services/:serviceId/edit', element: <AdminServiceEditPage /> },
{ path: 'categories/new', element: <AdminServiceCategoryCreatePage /> },
{ path: 'categories', element: <AdminDashboardServiceCategories /> },
{ path: 'categories/:categoryId/edit', element: <AdminServiceCategoryEditPage /> },
{ path: 'leads', element: <AdminDashboardLeads /> },
{ path: 'admins', element: <AdminDashboardAdmins /> },
],
},
]);
import { AboutPage } from '../../modules/about/pages/about.tsx';

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,68 +1,4 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@import '@fontsource/roboto/300.css';
@import '@fontsource/roboto/400.css';
@import '@fontsource/roboto/500.css';
@import '@fontsource/roboto/700.css';

View File

@@ -0,0 +1,135 @@
import { Fragment } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Header } from '../../../shared/components/header.tsx';
import { Footer } from '../../../shared/components/footer.tsx';
import { usePageTitle } from '../../../shared/hooks/usePageTitle.ts';
const aboutFacts = [
{
title: 'Год основания',
value: '1841',
description: 'Сбербанк ведёт свою историю от Сберегательных касс Российской империи.',
},
{
title: 'Генеральный директор',
value: 'Герман Греф',
description: 'Глава ПАО «Сбербанк» с 2007 года, проводит масштабную цифровую трансформацию.',
},
{
title: 'Офисы и представительства',
value: 'Крупнейшая сеть в России и СНГ',
description: 'Более 14 тысяч отделений и присутствие в большинстве регионов страны.',
},
];
const performanceMetrics = [
{ label: 'Совокупные активы', value: '≈ 45 трлн ₽', note: 'по МСФО за 2023 год' },
{ label: 'Чистая прибыль', value: '≈ 1,5 трлн ₽', note: 'рекордный результат 2023 года' },
{ label: 'Доля безналичных операций', value: '> 95%', note: 'в розничных транзакциях клиентов' },
];
const strategicDirections = [
'Развитие экосистемы сервисов: финансы, e-commerce, телемедицина, образование и логистика.',
'Лидерство в технологиях ИИ и машинного обучения для обслуживания клиентов и внутренней автоматизации.',
'Ответственное развитие: снижение углеродного следа, «зелёные» ипотека и облигации, благотворительные программы.',
];
export const AboutPage: React.FC = () => {
usePageTitle('О компании');
return (
<Fragment>
<Header />
<Box component="main" sx={{ pt: { xs: 12, md: 18 }, pb: 8, backgroundColor: 'background.default' }}>
<Container maxWidth="lg">
<Stack spacing={4}>
<Stack spacing={1} textAlign="center">
<Typography variant="h2" component="h1">
О компании «Сбербанк»
</Typography>
<Typography color="text.secondary" maxWidth={720} alignSelf="center">
Крупнейший финансовый институт России и Восточной Европы, формирующий экосистему цифровых сервисов для миллионов клиентов.
</Typography>
</Stack>
<Grid container spacing={2}>
{aboutFacts.map((fact) => (
<Grid key={fact.title} size={{ xs: 12, md: 4 }}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<Typography variant="overline" color="text.secondary">
{fact.title}
</Typography>
<Typography variant="h5" component="p" sx={{ mt: 1 }}>
{fact.value}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{fact.description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Card variant="outlined">
<CardContent>
<Typography variant="h5" gutterBottom>
Финансовые показатели
</Typography>
<Grid container spacing={2}>
{performanceMetrics.map((metric) => (
<Grid key={metric.label} size={{ xs: 12, md: 4 }}>
<Typography variant="subtitle2" color="text.secondary">
{metric.label}
</Typography>
<Typography variant="h4">{metric.value}</Typography>
<Typography variant="body2" color="text.secondary">
{metric.note}
</Typography>
</Grid>
))}
</Grid>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="h5" gutterBottom>
Стратегия и направления роста
</Typography>
<Stack component="ul" spacing={1} sx={{ pl: 3, listStyle: 'disc' }}>
{strategicDirections.map((item) => (
<Typography key={item} component="li" variant="body1">
{item}
</Typography>
))}
</Stack>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="h5" gutterBottom>
Социальная роль
</Typography>
<Typography variant="body1" color="text.secondary">
Сбербанк активно поддерживает предпринимателей и частных клиентов, инвестирует в инфраструктуру, образование и социальные проекты.
Особое внимание уделяется доступности финансовых услуг, цифровой безопасности и развитию регионов присутствия.
</Typography>
</CardContent>
</Card>
</Stack>
</Container>
</Box>
<Footer />
</Fragment>
);
};

View File

@@ -0,0 +1,427 @@
import React, { useEffect, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useNavigate } from 'react-router';
import {
adminApi,
type AdminProfile,
type AdminRegisterPayload,
type AdminPasswordChangePayload,
} from '../../../../api/index.ts';
import { ApiError } from '../../../../api/httpClient.ts';
type PasswordFormState = {
current: string;
password: string;
confirm: string;
};
type RegisterFormState = {
username: string;
password: string;
confirm: string;
};
type FormStatus = {
isSubmitting: boolean;
success: string | null;
error: string | null;
};
const defaultPasswordForm: PasswordFormState = {
current: '',
password: '',
confirm: '',
};
const defaultRegisterForm: RegisterFormState = {
username: '',
password: '',
confirm: '',
};
const defaultStatus: FormStatus = {
isSubmitting: false,
success: null,
error: null,
};
const formatDate = (value?: string) => {
if (!value) {
return '—';
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return '—';
}
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
};
const validatePasswordForm = (form: PasswordFormState) => {
if (!form.current) {
return 'Введите текущий пароль.';
}
if (!form.password || form.password.length < 8) {
return 'Пароль должен содержать минимум 8 символов.';
}
if (form.password !== form.confirm) {
return 'Пароли не совпадают.';
}
return null;
};
const validateRegisterForm = (form: RegisterFormState) => {
if (!form.username || form.username.length < 3) {
return 'Логин должен содержать минимум 3 символа.';
}
if (!form.password || form.password.length < 8) {
return 'Пароль должен содержать минимум 8 символов.';
}
if (form.password !== form.confirm) {
return 'Пароли не совпадают.';
}
return null;
};
export const AdminDashboardAdmins: React.FC = () => {
const [profile, setProfile] = useState<AdminProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
const [profileError, setProfileError] = useState<string | null>(null);
const [passwordForm, setPasswordForm] = useState<PasswordFormState>(defaultPasswordForm);
const [passwordStatus, setPasswordStatus] = useState<FormStatus>(defaultStatus);
const [registerForm, setRegisterForm] = useState<RegisterFormState>(defaultRegisterForm);
const [registerStatus, setRegisterStatus] = useState<FormStatus>(defaultStatus);
const [deleteStatus, setDeleteStatus] = useState<FormStatus>(defaultStatus);
const navigate = useNavigate();
useEffect(() => {
const loadProfile = async () => {
setIsLoadingProfile(true);
try {
const response = await adminApi.profile();
setProfile(response);
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить профиль администратора.';
setProfileError(message);
} finally {
setIsLoadingProfile(false);
}
};
void loadProfile();
}, []);
const profileDetails = useMemo(() => {
if (!profile) {
return null;
}
return [
{ label: 'ID', value: profile.id },
{ label: 'Логин', value: profile.username },
{ label: 'Создан', value: formatDate(profile.createdAt) },
];
}, [profile]);
const handlePasswordChange = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!profile) {
return;
}
const validationError = validatePasswordForm(passwordForm);
if (validationError) {
setPasswordStatus({
isSubmitting: false,
success: null,
error: validationError,
});
return;
}
const payload: AdminPasswordChangePayload = {
currentPassword: passwordForm.current.trim(),
newPassword: passwordForm.password.trim(),
};
const submit = async () => {
setPasswordStatus({ isSubmitting: true, success: null, error: null });
try {
await adminApi.changePassword(profile.id, payload);
setPasswordStatus({ isSubmitting: false, success: 'Пароль успешно обновлён.', error: null });
setPasswordForm(defaultPasswordForm);
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось обновить пароль. Попробуйте позже.';
setPasswordStatus({ isSubmitting: false, success: null, error: message });
}
};
void submit();
};
const handleRegisterSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const validationError = validateRegisterForm(registerForm);
if (validationError) {
setRegisterStatus({ isSubmitting: false, success: null, error: validationError });
return;
}
const payload: AdminRegisterPayload = {
username: registerForm.username.trim(),
password: registerForm.password.trim(),
};
const submit = async () => {
setRegisterStatus({ isSubmitting: true, success: null, error: null });
try {
const response = await adminApi.registerAdmin(payload);
setRegisterStatus({
isSubmitting: false,
success: `Администратор ${response.username} зарегистрирован.`,
error: null,
});
setRegisterForm(defaultRegisterForm);
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось зарегистрировать администратора. Попробуйте позже.';
setRegisterStatus({ isSubmitting: false, success: null, error: message });
}
};
void submit();
};
const handleDeleteAccount = async () => {
if (!profile) {
return;
}
const confirmed = window.confirm('Удалить ваш аккаунт администратора? Действие необратимо.');
if (!confirmed) {
return;
}
setDeleteStatus({ isSubmitting: true, success: null, error: null });
try {
await adminApi.deleteAdmin(profile.id);
setDeleteStatus({ isSubmitting: false, success: 'Аккаунт удалён. Перенаправляем...', error: null });
adminApi.logout();
setTimeout(() => {
navigate('/admin');
}, 1200);
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось удалить аккаунт. Попробуйте позже.';
setDeleteStatus({ isSubmitting: false, success: null, error: message });
}
};
return (
<Stack spacing={4}>
<div>
<Typography variant="h5">Управление доступом</Typography>
<Typography color="text.secondary">
Обновите пароль, удалите свой доступ или создайте учётную запись для коллеги.
</Typography>
</div>
{isLoadingProfile && (
<Alert severity="info" sx={{ width: 'fit-content' }}>
Загружаем профиль администратора...
</Alert>
)}
{profileError && <Alert severity="error">{profileError}</Alert>}
{profile && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 3 }}>
<Typography variant="h6" gutterBottom>
Текущий администратор
</Typography>
<Stack spacing={1}>
{profileDetails?.map((detail) => (
<Stack key={detail.label} direction="row" spacing={1} sx={{ flexWrap: 'wrap' }}>
<Typography sx={{ minWidth: 120 }} color="text.secondary">
{detail.label}:
</Typography>
<Typography fontWeight={600}>{detail.value}</Typography>
</Stack>
))}
</Stack>
</Box>
)}
<Divider />
<Box component="section" sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 3 }}>
<Typography variant="h6" gutterBottom>
Смена пароля
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Новый пароль должен содержать минимум 8 символов. Введите его дважды для подтверждения.
</Typography>
<Stack component="form" spacing={2} onSubmit={handlePasswordChange}>
{passwordStatus.error && <Alert severity="error">{passwordStatus.error}</Alert>}
{passwordStatus.success && <Alert severity="success">{passwordStatus.success}</Alert>}
<TextField
type="password"
label="Текущий пароль"
value={passwordForm.current}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
current: event.target.value,
}))
}
required
autoComplete="current-password"
/>
<TextField
type="password"
label="Новый пароль"
value={passwordForm.password}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
password: event.target.value,
}))
}
required
autoComplete="new-password"
/>
<TextField
type="password"
label="Подтвердите пароль"
value={passwordForm.confirm}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
confirm: event.target.value,
}))
}
required
autoComplete="new-password"
/>
<Button type="submit" variant="contained" disabled={passwordStatus.isSubmitting || !profile} sx={{ alignSelf: 'flex-start', minWidth: 220 }}>
{passwordStatus.isSubmitting ? 'Обновляем...' : 'Обновить пароль'}
</Button>
</Stack>
</Box>
<Divider />
<Box component="section" sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 3 }}>
<Typography variant="h6" gutterBottom>
Регистрация нового администратора
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Создайте дополнительный доступ для коллеги. Сохраните пароль и передайте его безопасным способом.
</Typography>
<Stack component="form" spacing={2} onSubmit={handleRegisterSubmit}>
{registerStatus.error && <Alert severity="error">{registerStatus.error}</Alert>}
{registerStatus.success && <Alert severity="success">{registerStatus.success}</Alert>}
<TextField
label="Логин"
value={registerForm.username}
onChange={(event) =>
setRegisterForm((prev) => ({
...prev,
username: event.target.value,
}))
}
required
autoComplete="username"
/>
<TextField
type="password"
label="Пароль"
value={registerForm.password}
onChange={(event) =>
setRegisterForm((prev) => ({
...prev,
password: event.target.value,
}))
}
required
autoComplete="new-password"
/>
<TextField
type="password"
label="Подтвердите пароль"
value={registerForm.confirm}
onChange={(event) =>
setRegisterForm((prev) => ({
...prev,
confirm: event.target.value,
}))
}
required
autoComplete="new-password"
/>
<Button type="submit" variant="outlined" disabled={registerStatus.isSubmitting} sx={{ alignSelf: 'flex-start', minWidth: 220 }}>
{registerStatus.isSubmitting ? 'Создаём...' : 'Создать администратора'}
</Button>
</Stack>
</Box>
<Divider />
<Box component="section" sx={{ border: 1, borderColor: 'error.main', borderRadius: 2, p: 3, backgroundColor: 'error.main', color: 'error.contrastText' }}>
<Typography variant="h6" gutterBottom>
Опасная зона
</Typography>
<Typography variant="body2" gutterBottom>
Полное удаление вашего аккаунта. После подтверждения доступ будет отозван, а вы будете перенаправлены к форме входа.
</Typography>
{deleteStatus.error && (
<Alert severity="error" sx={{ mt: 2, backgroundColor: 'warning.light', color: 'text.primary' }}>
{deleteStatus.error}
</Alert>
)}
{deleteStatus.success && (
<Alert severity="success" sx={{ mt: 2, backgroundColor: 'success.light', color: 'text.primary' }}>
{deleteStatus.success}
</Alert>
)}
<Button
variant="contained"
color="inherit"
sx={{
mt: 2,
minWidth: 220,
backgroundColor: 'background.paper',
color: 'error.main',
'&:hover': { backgroundColor: 'grey.100' },
}}
onClick={handleDeleteAccount}
disabled={deleteStatus.isSubmitting || !profile}
>
{deleteStatus.isSubmitting ? 'Удаляем...' : 'Удалить мой аккаунт'}
</Button>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,149 @@
import React, { useMemo, useState } from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import MenuIcon from '@mui/icons-material/Menu';
import Paper from '@mui/material/Paper';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { adminApi } from '../../../api';
const drawerWidth = 280;
const sections = [
{ id: 'news', label: 'Новости', path: 'news' },
{ id: 'services', label: 'Услуги', path: 'services' },
{ id: 'categories', label: 'Категории услуг', path: 'categories' },
{ id: 'leads', label: 'Лиды', path: 'leads' },
{ id: 'admins', label: 'Администрирование', path: 'admins' },
] as const;
export const AdminDashboardLayout: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const activeSection = useMemo(() => {
const matched = sections.find((section) => location.pathname.includes(section.path));
return matched ?? sections[0];
}, [location.pathname]);
const handleDrawerToggle = () => {
setMobileOpen((prev) => !prev);
};
const handleLogout = () => {
adminApi.logout();
setMobileOpen(false);
navigate('/admin');
};
const drawer = (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Toolbar>
<Typography variant="h6" component="div">
Панель управления
</Typography>
</Toolbar>
<Divider />
<List sx={{ flexGrow: 1 }}>
{sections.map((item) => (
<ListItem key={item.id} disablePadding>
<ListItemButton
component={NavLink}
to={item.path}
relative="path"
selected={item.id === activeSection.id}
onClick={() => setMobileOpen(false)}
>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<Box sx={{ p: 2 }}>
<Button fullWidth variant="outlined" color="inherit" onClick={handleLogout}>
Выйти
</Button>
</Box>
</Box>
);
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar
position="fixed"
elevation={0}
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
ml: { md: `${drawerWidth}px` },
backgroundColor: (theme) => theme.palette.background.paper,
color: (theme) => theme.palette.text.primary,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Toolbar>
<IconButton color="inherit" aria-label="open drawer" edge="start" onClick={handleDrawerToggle} sx={{ mr: 2, display: { md: 'none' } }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{activeSection.label}
</Typography>
</Toolbar>
</AppBar>
<Box component="nav" sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }} aria-label="admin navigation">
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: { xs: 2, md: 4 },
width: { md: `calc(100% - ${drawerWidth}px)` },
mt: { xs: 7, md: 0 },
}}
>
<Toolbar sx={{ display: { xs: 'block', md: 'none' } }} />
<Paper sx={{ p: { xs: 2, md: 4 }, minHeight: '50vh', mt: { xs: 0, md: 8, lg: 8 } }} elevation={1}>
<Outlet />
</Paper>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,160 @@
import React, { useEffect, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Pagination from '@mui/material/Pagination';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { leadsApi, type LeadListItem } from '../../../../api/index.ts';
import { ApiError } from '../../../../api/httpClient.ts';
const pageSize = 10;
const formatDateTime = (value: string) => {
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return '—';
}
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
};
export const AdminDashboardLeads: React.FC = () => {
const [items, setItems] = useState<LeadListItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [limit, setLimit] = useState(pageSize);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
setError(null);
try {
const response = await leadsApi.list({
limit: pageSize,
page,
});
if (!isMounted) {
return;
}
setItems(response.items);
setTotal(response.total);
setLimit(response.limit || pageSize);
} catch (err) {
if (!isMounted) {
return;
}
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить лиды. Попробуйте позже.';
setError(message);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void load();
return () => {
isMounted = false;
};
}, [page]);
const totalPages = useMemo(() => {
if (total > 0 && limit > 0) {
return Math.max(Math.ceil(total / limit), 1);
}
return items.length > 0 ? page : 0;
}, [items.length, limit, page, total]);
useEffect(() => {
if (!isLoading && totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [isLoading, page, totalPages]);
return (
<Stack spacing={3}>
<div>
<Typography variant="h5">Лиды и заявки</Typography>
<Typography color="text.secondary">Просматривайте обращения клиентов и назначайте ответственных.</Typography>
</div>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{error && <Alert severity="error">{error}</Alert>}
{!isLoading && !error && items.length === 0 && (
<Typography color="text.secondary" textAlign="center">
Пока нет новых заявок. Как только клиенты оставят контакты, они появятся здесь.
</Typography>
)}
{!isLoading && !error && items.length > 0 && (
<Table
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
'& th, & td': {
borderRight: '1px solid',
borderColor: 'divider',
'&:last-of-type': {
borderRight: 'none',
},
},
}}
>
<TableHead>
<TableRow sx={{ backgroundColor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 600 }}>Дата и время</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Имя</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Email</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Телефон</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} hover sx={{ '& td': { borderBottom: '1px solid', borderColor: 'divider' } }}>
<TableCell>{formatDateTime(item.createdAt ?? '')}</TableCell>
<TableCell>
<Typography fontWeight={600}>{item.fullName}</Typography>
</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell>{item.phone ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{!isLoading && !error && totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination page={page} onChange={(_, value) => setPage(value)} count={totalPages} color="primary" showFirstButton showLastButton />
</Box>
)}
</Stack>
);
};

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Container from '@mui/material/Container';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useNavigate } from 'react-router';
import { adminApi } from '../../../api/index.ts';
import { ApiError, getAdminAuthToken } from '../../../api/httpClient.ts';
const initialForm = {
username: '',
password: '',
};
export const AdminLoginForm: React.FC = () => {
const navigate = useNavigate();
const [form, setForm] = useState(initialForm);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCheckingSession, setIsCheckingSession] = useState(false);
useEffect(() => {
const token = getAdminAuthToken();
if (!token) {
return;
}
const verify = async () => {
setIsCheckingSession(true);
try {
const profile = await adminApi.profile();
setSuccess(`Вы уже вошли как ${profile.username}. Перенаправляем...`);
navigate('/admin/dashboard');
} catch (err) {
adminApi.logout();
if (err instanceof ApiError) {
setError('Сессия истекла. Пожалуйста, войдите снова.');
} else {
setError('Не удалось проверить текущую сессию. Попробуйте снова.');
}
} finally {
setIsCheckingSession(false);
}
};
void verify();
}, [navigate]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.username || !form.password) {
setError('Введите логин и пароль');
return;
}
setIsSubmitting(true);
setError(null);
setSuccess(null);
try {
await adminApi.login(form);
setSuccess('Вы успешно вошли в админ-панель.');
navigate('/admin/dashboard');
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не удалось выполнить вход. Повторите попытку.');
} else {
setError('Не удалось выполнить вход. Повторите попытку.');
}
} finally {
setIsSubmitting(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: (theme) => theme.palette.grey[100],
py: 4,
}}
>
<Container maxWidth="sm">
<Paper elevation={3} sx={{ p: { xs: 3, md: 4 } }}>
<Stack spacing={3} component="form" onSubmit={handleSubmit}>
<div>
<Typography variant="h4" component="h1" gutterBottom>
Вход в админ-панель
</Typography>
<Typography variant="body2" color="text.secondary">
Используйте учётные данные администратора
</Typography>
</div>
{error && <Alert severity="error">{error}</Alert>}
{isCheckingSession && !error && <Alert severity="info">Проверяем активную сессию...</Alert>}
{success && <Alert severity="success">{success}</Alert>}
<TextField
required
label="Имя пользователя"
name="username"
value={form.username}
onChange={handleChange}
autoComplete="username"
fullWidth
/>
<TextField
required
label="Пароль"
type="password"
name="password"
value={form.password}
onChange={handleChange}
autoComplete="current-password"
fullWidth
/>
<Button type="submit" variant="contained" disabled={isSubmitting || isCheckingSession}>
{isSubmitting ? 'Входим…' : 'Войти'}
</Button>
</Stack>
</Paper>
</Container>
</Box>
);
};

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Pagination from '@mui/material/Pagination';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/EditRounded';
import DeleteIcon from '@mui/icons-material/DeleteRounded';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useStore } from '../../../../shared/hooks/useStore.ts';
import { formatDate, getDateValue } from '../../../news/utils/formatDate.ts';
const AdminDashboardNewsComponent: React.FC = () => {
const navigate = useNavigate();
const { news } = useStore();
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
void news.fetchAdmin({ limit: pageSize, page }, { replace: true });
}, [news, page]);
const sortedNews = useMemo(
() => news.list.slice().sort((a, b) => getDateValue(b.publishedAt) - getDateValue(a.publishedAt)),
[news.list],
);
const totalPages =
news.limit && news.total ? Math.ceil(news.total / news.limit) : sortedNews.length === pageSize ? page + 1 : page;
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
const handleAdd = () => {
navigate('/admin/dashboard/news/new');
};
const handleEdit = (slug: string) => {
if (!slug) {
return;
}
navigate(`/admin/dashboard/news/${slug}/edit`);
};
const handleDelete = (id: string) => {
console.info('Delete news action is not implemented yet', id);
};
return (
<Stack spacing={3}>
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }} spacing={2}>
<div>
<Typography variant="h5">Новости</Typography>
<Typography color="text.secondary">Управляйте опубликованными материалами и следите за актуальностью.</Typography>
</div>
<Button onClick={handleAdd} variant="contained" size="small">
Добавить новость
</Button>
</Stack>
{news.isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{news.error && <Alert severity="error">{news.error}</Alert>}
{!news.isLoading && !news.error && sortedNews.length === 0 && (
<Typography color="text.secondary" textAlign="center">
Ни одной новости пока не добавлено.
</Typography>
)}
{!news.isLoading && !news.error && sortedNews.length > 0 && (
<Table
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
'& th, & td': {
borderRight: '1px solid',
borderColor: 'divider',
'&:last-of-type': {
borderRight: 'none',
},
},
}}
>
<TableHead>
<TableRow sx={{ backgroundColor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 600 }}>Заголовок</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Дата публикации</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Краткое описание</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Статус</TableCell>
<TableCell align="left" sx={{ fontWeight: 600 }}>
Действия
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedNews.map((item) => (
<TableRow key={item.id} hover sx={{ '& td': { borderBottom: '1px solid', borderColor: 'divider' } }}>
<TableCell sx={{ maxWidth: 220 }}>
<Typography fontWeight={600}>{item.title}</Typography>
<Typography variant="caption" color="text.secondary">
{item.slug}
</Typography>
</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{formatDate(item.publishedAt)}</TableCell>
<TableCell>
<Typography
variant="body2"
color="text.secondary"
sx={{ display: '-webkit-box', overflow: 'hidden', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}
>
{item.summary}
</Typography>
</TableCell>
<TableCell>
<Chip
size="small"
label={item.status}
color={item.status === 'published' ? 'success' : item.status === 'draft' ? 'default' : 'warning'}
sx={{ textTransform: 'capitalize' }}
/>
</TableCell>
<TableCell align="left">
<Tooltip title="Редактировать" sx={{ mr: 1 }}>
<IconButton size="small" onClick={() => handleEdit(item.slug)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton size="small" color="error" onClick={() => handleDelete(item.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{!news.isLoading && !news.error && sortedNews.length > 0 && totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
page={page}
onChange={(_, value) => setPage(value)}
count={totalPages}
color="primary"
showFirstButton
showLastButton
/>
</Box>
)}
</Stack>
);
};
export const AdminDashboardNews = observer(AdminDashboardNewsComponent);

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import MenuItem from '@mui/material/MenuItem';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminNewsCreatePayload } from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { useStore } from '../../../../shared/hooks/useStore.ts';
const defaultForm: AdminNewsCreatePayload = {
title: '',
slug: '',
summary: '',
content: '',
imageUrl: '',
status: 'draft',
};
export const AdminNewsCreateForm: React.FC = () => {
const navigate = useNavigate();
const { news } = useStore();
const [form, setForm] = useState(defaultForm);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
setError(null);
setSuccess(null);
const payload: AdminNewsCreatePayload = {
title: form.title.trim(),
slug: form.slug.trim(),
summary: form.summary.trim(),
content: form.content.trim(),
imageUrl: form.imageUrl?.trim() || null,
status: form.status,
};
try {
await adminApi.createNews(payload);
await news.fetchBySlug(payload.slug);
setSuccess('Новость создана.');
navigate(`/admin/dashboard/news`, { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не удалось создать новость.');
} else {
setError('Не удалось создать новость.');
}
} finally {
setIsSaving(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
<div>
<Typography variant="h5">Новая новость</Typography>
<Typography color="text.secondary">Заполните данные и сохраните черновик или публикацию.</Typography>
</div>
<Button variant="outlined" size="small" onClick={() => navigate('/admin/dashboard/news')}>
Назад к списку
</Button>
</Stack>
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">{success}</Alert>}
<TextField label="Заголовок" name="title" required value={form.title} onChange={handleChange} fullWidth />
<TextField label="Slug" name="slug" required value={form.slug} onChange={handleChange} helperText="Используется в URL новости" fullWidth />
<TextField label="Краткое описание" name="summary" required value={form.summary} onChange={handleChange} fullWidth />
<TextField label="Содержимое" name="content" required multiline rows={6} value={form.content} onChange={handleChange} />
<TextField
label="Изображение (URL)"
name="imageUrl"
value={form.imageUrl}
onChange={handleChange}
placeholder="https://example.com/image.jpg"
fullWidth
/>
<TextField select label="Статус" name="status" value={form.status} onChange={handleChange} required fullWidth>
<MenuItem value="draft">Черновик</MenuItem>
<MenuItem value="published">Опубликовано</MenuItem>
<MenuItem value="archived">Архив</MenuItem>
</TextField>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? 'Сохраняем…' : 'Создать новость'}
</Button>
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import MenuItem from '@mui/material/MenuItem';
import { useNavigate, useParams } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import { adminApi } from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { useStore } from '../../../../shared/hooks/useStore.ts';
const AdminNewsEditFormComponent: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { news } = useStore();
const currentNews = slug ? news.getBySlug(slug) : undefined;
const [form, setForm] = useState({
title: '',
slug: '',
summary: '',
content: '',
imageUrl: '',
status: 'draft' as 'draft' | 'published' | 'archived',
});
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (slug && !currentNews && !news.isLoading) {
void news.fetchBySlug(slug).catch(() => {
/* error handled via store */
});
}
}, [slug, currentNews, news]);
useEffect(() => {
if (!currentNews) {
return;
}
setForm({
title: currentNews.title,
slug: currentNews.slug,
summary: currentNews.summary,
content: currentNews.content,
imageUrl: currentNews.imageUrl ?? '',
status: currentNews.status,
});
}, [currentNews, currentNews?.id]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!slug) {
return;
}
setIsSaving(true);
setError(null);
setSuccess(null);
try {
const payload = {
title: form.title.trim(),
slug: form.slug.trim(),
summary: form.summary.trim(),
content: form.content.trim(),
imageUrl: form.imageUrl.trim() || undefined,
status: form.status,
};
await adminApi.updateNews(slug, payload);
await news.fetchBySlug(payload.slug);
setSuccess('Новость успешно обновлена.');
if (payload.slug !== slug) {
navigate(`/admin/dashboard/news/${payload.slug}/edit`, { replace: true });
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не удалось сохранить изменения.');
} else {
setError('Не удалось сохранить изменения.');
}
} finally {
setIsSaving(false);
}
};
const isLoadingEntry = news.isLoading && !currentNews;
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
<div>
<Typography variant="h5">Редактирование новости</Typography>
<Typography color="text.secondary">Обновите содержимое и сохраните изменения.</Typography>
</div>
<Button variant="outlined" size="small" onClick={() => navigate('/admin/dashboard/news')}>
Назад к списку
</Button>
</Stack>
{isLoadingEntry && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{!isLoadingEntry && news.error && <Alert severity="error">{news.error}</Alert>}
{!isLoadingEntry && !currentNews && !news.error && <Alert severity="warning">Новость не найдена. Возможно, она была удалена.</Alert>}
{!isLoadingEntry && currentNews && (
<Stack spacing={2}>
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">{success}</Alert>}
<TextField label="Заголовок" name="title" required value={form.title} onChange={handleChange} fullWidth />
<TextField label="Slug" name="slug" required value={form.slug} onChange={handleChange} helperText="Используется в URL новости" fullWidth />
<TextField label="Краткое описание" name="summary" required value={form.summary} onChange={handleChange} fullWidth />
<TextField label="Содержимое" name="content" required multiline rows={6} value={form.content} onChange={handleChange} fullWidth />
<TextField
label="Изображение (URL)"
name="imageUrl"
value={form.imageUrl}
onChange={handleChange}
placeholder="https://example.com/image.jpg"
fullWidth
/>
<TextField select label="Статус" name="status" value={form.status} onChange={handleChange} required fullWidth>
<MenuItem value="draft">Черновик</MenuItem>
<MenuItem value="published">Опубликовано</MenuItem>
<MenuItem value="archived">Архив</MenuItem>
</TextField>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? 'Сохраняем…' : 'Сохранить изменения'}
</Button>
</Stack>
</Stack>
)}
</Box>
);
};
export const AdminNewsEditForm = observer(AdminNewsEditFormComponent);

View File

@@ -0,0 +1,131 @@
import { useEffect } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/EditRounded';
import DeleteIcon from '@mui/icons-material/DeleteRounded';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useStore } from '../../../../shared/hooks/useStore.ts';
const AdminDashboardServiceCategoriesComponent: React.FC = () => {
const { adminServiceCategories } = useStore();
const navigate = useNavigate();
useEffect(() => {
if (!adminServiceCategories.isLoading && adminServiceCategories.isEmpty) {
void adminServiceCategories.fetch();
}
}, [adminServiceCategories]);
const sortedCategories = adminServiceCategories.list.slice().sort((a, b) => a.name.localeCompare(b.name, 'ru'));
const handleAdd = () => {
navigate('/admin/dashboard/categories/new');
};
const handleEdit = (id: string) => {
navigate(`/admin/dashboard/categories/${id}/edit`);
};
const handleDelete = (id: string) => {
console.info('Delete category action is not implemented yet', id);
};
return (
<Stack spacing={3}>
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }} spacing={2}>
<div>
<Typography variant="h5">Категории услуг</Typography>
<Typography color="text.secondary">Создавайте и редактируйте категории для группировки услуг.</Typography>
</div>
<Button onClick={handleAdd} variant="contained" size="small">
Добавить категорию
</Button>
</Stack>
{adminServiceCategories.isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{adminServiceCategories.error && <Alert severity="error">{adminServiceCategories.error}</Alert>}
{!adminServiceCategories.isLoading && !adminServiceCategories.error && sortedCategories.length === 0 && (
<Typography color="text.secondary" textAlign="center">
Категории пока не добавлены.
</Typography>
)}
{!adminServiceCategories.isLoading && !adminServiceCategories.error && sortedCategories.length > 0 && (
<Table
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
'& th, & td': {
borderRight: '1px solid',
borderColor: 'divider',
'&:last-of-type': {
borderRight: 'none',
},
},
}}
>
<TableHead>
<TableRow sx={{ backgroundColor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 600 }}>Название</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Slug</TableCell>
<TableCell align="left" sx={{ fontWeight: 600 }}>
Действия
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedCategories.map((category) => (
<TableRow key={category.id} hover sx={{ '& td': { borderBottom: '1px solid', borderColor: 'divider' } }}>
<TableCell>
<Typography fontWeight={600}>{category.name}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{category.slug}
</Typography>
</TableCell>
<TableCell align="left">
<Tooltip title="Редактировать" sx={{ mr: 1 }}>
<IconButton size="small" onClick={() => handleEdit(category.id)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton size="small" color="error" onClick={() => handleDelete(category.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Stack>
);
};
export const AdminDashboardServiceCategories = observer(AdminDashboardServiceCategoriesComponent);

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useNavigate } from 'react-router-dom';
import { adminApi } from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { useStore } from '../../../../shared/hooks/useStore.ts';
import { ServiceCategoryForm } from './service-category-form.tsx';
import {
defaultServiceCategoryFormValues,
mapCategoryFormToCreatePayload,
type ServiceCategoryFormValues,
} from './service-category-form.helpers.ts';
export const AdminServiceCategoryCreateForm: React.FC = () => {
const navigate = useNavigate();
const { adminServiceCategories } = useStore();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (values: ServiceCategoryFormValues) => {
setError(null);
setSuccess(null);
setIsSubmitting(true);
try {
const payload = mapCategoryFormToCreatePayload(values);
await adminApi.createServiceCategory(payload);
await adminServiceCategories.fetch();
setSuccess('Категория создана.');
navigate('/admin/dashboard/categories', { replace: true });
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось создать категорию. Попробуйте позже.';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', md: 'center' }}>
<div>
<Typography variant="h5">Новая категория</Typography>
<Typography color="text.secondary">Введите название и slug категории.</Typography>
</div>
<Button variant="outlined" size="small" onClick={() => navigate(-1)}>
Назад
</Button>
</Stack>
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">{success}</Alert>}
<ServiceCategoryForm
initialValues={defaultServiceCategoryFormValues}
submitLabel="Создать категорию"
onSubmit={handleSubmit}
onCancel={() => navigate('/admin/dashboard/categories')}
isSubmitting={isSubmitting}
/>
</Box>
);
};

View File

@@ -0,0 +1,151 @@
import { useEffect, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi } from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { useStore } from '../../../../shared/hooks/useStore.ts';
import { ServiceCategoryForm } from './service-category-form.tsx';
import {
createServiceCategoryFormValues,
mapCategoryFormToUpdatePayload,
type ServiceCategoryFormValues,
} from './service-category-form.helpers.ts';
export const AdminServiceCategoryEditForm: React.FC = () => {
const navigate = useNavigate();
const { categoryId } = useParams<{ categoryId: string }>();
const { adminServiceCategories } = useStore();
const [isFetching, setIsFetching] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null);
const currentCategory = useMemo(() => {
if (!categoryId) {
return null;
}
return adminServiceCategories.getById(categoryId) ?? null;
}, [adminServiceCategories, categoryId]);
useEffect(() => {
if (adminServiceCategories.isLoading || adminServiceCategories.list.length > 0 || !categoryId || currentCategory) {
return;
}
const load = async () => {
setIsFetching(true);
setFetchError(null);
try {
await adminServiceCategories.fetch();
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить категории. Попробуйте позже.';
setFetchError(message);
} finally {
setIsFetching(false);
}
};
void load();
}, [adminServiceCategories, categoryId, currentCategory]);
const initialValues = useMemo(() => {
if (!currentCategory) {
return createServiceCategoryFormValues();
}
return createServiceCategoryFormValues(currentCategory.toJSON());
}, [currentCategory]);
const handleSubmit = async (values: ServiceCategoryFormValues) => {
if (!categoryId) {
setFormError('Не указан идентификатор категории.');
return;
}
setFormError(null);
setFormSuccess(null);
setIsSubmitting(true);
try {
const payload = mapCategoryFormToUpdatePayload(values);
await adminApi.updateServiceCategory(categoryId, payload);
await adminServiceCategories.fetch();
setFormSuccess('Категория обновлена.');
navigate('/admin/dashboard/categories');
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось обновить категорию. Попробуйте позже.';
setFormError(message);
} finally {
setIsSubmitting(false);
}
};
if (!categoryId) {
return (
<Alert severity="error">
Не указан идентификатор категории. Вернитесь к списку и попробуйте снова.
</Alert>
);
}
if (isFetching && !currentCategory) {
return (
<Stack spacing={2} alignItems="center">
<CircularProgress size={32} />
<Typography>Загружаем категорию...</Typography>
</Stack>
);
}
if (fetchError && !currentCategory) {
return (
<Stack spacing={2}>
<Alert severity="error">{fetchError}</Alert>
<Button variant="outlined" onClick={() => navigate('/admin/dashboard/categories')}>
Вернуться к списку
</Button>
</Stack>
);
}
if (!currentCategory) {
return (
<Alert severity="warning">
Категория не найдена. Возможно, она была удалена. Вернитесь к списку.
</Alert>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', md: 'center' }}>
<div>
<Typography variant="h5">Редактирование категории</Typography>
<Typography color="text.secondary">{currentCategory.name}</Typography>
</div>
<Button variant="outlined" size="small" onClick={() => navigate(-1)}>
Назад
</Button>
</Stack>
{formError && <Alert severity="error">{formError}</Alert>}
{formSuccess && <Alert severity="success">{formSuccess}</Alert>}
<ServiceCategoryForm
initialValues={initialValues}
submitLabel="Сохранить категорию"
onSubmit={handleSubmit}
onCancel={() => navigate('/admin/dashboard/categories')}
isSubmitting={isSubmitting}
/>
</Box>
);
};

View File

@@ -0,0 +1,36 @@
import type {
AdminServiceCategoryCreatePayload,
AdminServiceCategoryUpdatePayload,
} from '../../../../api';
import type { AdminServiceCategory } from '../../../../api/index.ts';
export type ServiceCategoryFormValues = {
name: string;
slug: string;
};
export const defaultServiceCategoryFormValues: ServiceCategoryFormValues = {
name: '',
slug: '',
};
export const createServiceCategoryFormValues = (
category?: Partial<AdminServiceCategory>,
): ServiceCategoryFormValues => ({
name: category?.name ?? '',
slug: category?.slug ?? '',
});
export const mapCategoryFormToCreatePayload = (
values: ServiceCategoryFormValues,
): AdminServiceCategoryCreatePayload => ({
name: values.name.trim(),
slug: values.slug.trim(),
});
export const mapCategoryFormToUpdatePayload = (
values: ServiceCategoryFormValues,
): AdminServiceCategoryUpdatePayload => ({
name: values.name.trim(),
slug: values.slug.trim(),
});

View File

@@ -0,0 +1,109 @@
import { useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import {
defaultServiceCategoryFormValues,
type ServiceCategoryFormValues,
} from './service-category-form.helpers.ts';
type FieldErrors = Partial<Record<keyof ServiceCategoryFormValues, string>>;
export interface ServiceCategoryFormProps {
initialValues?: ServiceCategoryFormValues;
submitLabel: string;
onSubmit: (values: ServiceCategoryFormValues) => Promise<void>;
onCancel: () => void;
isSubmitting: boolean;
}
export const ServiceCategoryForm: React.FC<ServiceCategoryFormProps> = ({
initialValues,
submitLabel,
onSubmit,
onCancel,
isSubmitting,
}) => {
const [form, setForm] = useState<ServiceCategoryFormValues>(initialValues ?? defaultServiceCategoryFormValues);
const [errors, setErrors] = useState<FieldErrors>({});
const initialKey = useMemo(() => JSON.stringify(initialValues ?? defaultServiceCategoryFormValues), [initialValues]);
useEffect(() => {
if (initialValues) {
setForm(initialValues);
} else {
setForm(defaultServiceCategoryFormValues);
}
}, [initialKey, initialValues]);
const validate = () => {
const next: FieldErrors = {};
if (!form.name.trim()) {
next.name = 'Введите название категории';
}
if (!form.slug.trim()) {
next.slug = 'Введите slug категории';
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
if (errors[name as keyof ServiceCategoryFormValues]) {
setErrors((prev) => ({
...prev,
[name]: undefined,
}));
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!validate()) {
return;
}
await onSubmit(form);
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Название"
name="name"
value={form.name}
onChange={handleChange}
required
error={Boolean(errors.name)}
helperText={errors.name}
fullWidth
/>
<TextField
label="Slug"
name="slug"
value={form.slug}
onChange={handleChange}
required
error={Boolean(errors.slug)}
helperText={errors.slug ?? 'Используется в URL категории'}
fullWidth
/>
<Stack direction="row" spacing={2} justifyContent="flex-end" flexWrap="wrap">
<Button variant="outlined" color="inherit" onClick={onCancel} disabled={isSubmitting}>
Отмена
</Button>
<Button type="submit" variant="contained" disabled={isSubmitting}>
{isSubmitting ? 'Сохраняем…' : submitLabel}
</Button>
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,170 @@
import React, { useEffect, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Pagination from '@mui/material/Pagination';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/EditRounded';
import DeleteIcon from '@mui/icons-material/DeleteRounded';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useStore } from '../../../../shared/hooks/useStore.ts';
import { formatPrice } from '../../../services/utils/formatPrice.ts';
const AdminDashboardServicesComponent: React.FC = () => {
const { services } = useStore();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
void services.fetchAdmin({ limit: pageSize, page }, { replace: true });
}, [services, page]);
const sortedServices = useMemo(
() => services.list.slice().sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
[services.list],
);
const totalPages =
services.limit && services.total
? Math.ceil(services.total / services.limit)
: sortedServices.length === pageSize
? page + 1
: page;
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
const handleAdd = () => {
navigate('/admin/dashboard/services/new');
};
const handleEdit = (id: string) => {
navigate(`/admin/dashboard/services/${id}/edit`);
};
const handleDelete = (id: string) => {
console.info('Delete service action is not implemented yet', id);
};
return (
<Stack spacing={3}>
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }} spacing={2}>
<div>
<Typography variant="h5">Услуги</Typography>
<Typography color="text.secondary">Добавляйте новые услуги, обновляйте цены и статусы.</Typography>
</div>
<Button onClick={handleAdd} variant="contained" size="small">
Добавить услугу
</Button>
</Stack>
{services.isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{services.error && <Alert severity="error">{services.error}</Alert>}
{!services.isLoading && !services.error && sortedServices.length === 0 && (
<Typography color="text.secondary" textAlign="center">
Услуги пока не добавлены.
</Typography>
)}
{!services.isLoading && !services.error && sortedServices.length > 0 && (
<Table
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
'& th, & td': {
borderRight: '1px solid',
borderColor: 'divider',
'&:last-of-type': {
borderRight: 'none',
},
},
}}
>
<TableHead>
<TableRow sx={{ backgroundColor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 600 }}>Название</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Категория</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Стоимость</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Статус</TableCell>
<TableCell align="left" sx={{ fontWeight: 600 }}>
Действия
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedServices.map((service) => (
<TableRow key={service.id} hover sx={{ '& td': { borderBottom: '1px solid', borderColor: 'divider' } }}>
<TableCell sx={{ maxWidth: 220 }}>
<Typography fontWeight={600}>{service.title}</Typography>
<Typography variant="caption" color="text.secondary">
{service.slug || service.id}
</Typography>
</TableCell>
<TableCell>{service.category?.name ?? 'Без категории'}</TableCell>
<TableCell>{formatPrice(service.priceFrom)}</TableCell>
<TableCell>
<Chip
size="small"
label={service.status || '—'}
color={service.status?.toUpperCase() === 'PUBLISHED' ? 'success' : 'default'}
/>
</TableCell>
<TableCell align="left">
<Tooltip title="Редактировать" sx={{ mr: 1 }}>
<IconButton size="small" onClick={() => handleEdit(service.id)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton size="small" color="error" onClick={() => handleDelete(service.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{!services.isLoading && !services.error && sortedServices.length > 0 && totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
page={page}
onChange={(_, value) => setPage(value)}
count={totalPages}
color="primary"
showFirstButton
showLastButton
/>
</Box>
)}
</Stack>
);
};
export const AdminDashboardServices = observer(AdminDashboardServicesComponent);

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useNavigate } from 'react-router-dom';
import { adminApi } from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { ServiceForm } from './service-form.tsx';
import { createServiceFormValues, mapServiceFormToCreatePayload, type ServiceFormValues } from './service-form.helpers.ts';
export const AdminServiceCreateForm: React.FC = () => {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (values: ServiceFormValues) => {
setError(null);
setSuccess(null);
setIsSubmitting(true);
try {
const payload = mapServiceFormToCreatePayload(values);
await adminApi.createService(payload);
setSuccess('Услуга успешно создана.');
navigate('/admin/dashboard/services', { replace: true });
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось создать услугу. Попробуйте позже.';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', md: 'center' }}>
<div>
<Typography variant="h5">Новая услуга</Typography>
<Typography color="text.secondary">Заполните данные и сохраните новую услугу.</Typography>
</div>
<Button variant="outlined" size="small" onClick={() => navigate(-1)}>
Назад
</Button>
</Stack>
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">{success}</Alert>}
<ServiceForm
mode="create"
initialValues={createServiceFormValues()}
isSubmitting={isSubmitting}
submitLabel="Создать услугу"
onSubmit={handleSubmit}
onCancel={() => navigate('/admin/dashboard/services')}
/>
</Box>
);
};

View File

@@ -0,0 +1,148 @@
import { useEffect, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi } from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { useStore } from '../../../../shared/hooks/useStore.ts';
import { ServiceForm } from './service-form.tsx';
import { createServiceFormValues, mapServiceFormToUpdatePayload, type ServiceFormValues } from './service-form.helpers.ts';
export const AdminServiceEditForm: React.FC = () => {
const navigate = useNavigate();
const { services } = useStore();
const { serviceId } = useParams<{ serviceId: string }>();
const [fetchError, setFetchError] = useState<string | null>(null);
const [isFetching, setIsFetching] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null);
const currentService = useMemo(() => {
if (!serviceId) {
return null;
}
return services.getById(serviceId) ?? services.getBySlug(serviceId) ?? null;
}, [serviceId, services]);
useEffect(() => {
if (!serviceId || currentService) {
return;
}
const load = async () => {
setIsFetching(true);
setFetchError(null);
try {
await services.fetchBySlugAdmin(serviceId);
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Не удалось загрузить услугу для редактирования. Попробуйте позже.';
setFetchError(message);
} finally {
setIsFetching(false);
}
};
void load();
}, [currentService, serviceId, services]);
const initialValues = useMemo(() => {
if (!currentService) {
return createServiceFormValues();
}
return createServiceFormValues(currentService.toJSON());
}, [currentService]);
const handleSubmit = async (values: ServiceFormValues) => {
if (!serviceId) {
setFormError('Не удалось определить идентификатор услуги.');
return;
}
setFormError(null);
setFormSuccess(null);
setIsSubmitting(true);
try {
const payload = mapServiceFormToUpdatePayload(values);
await adminApi.updateService(serviceId, payload);
setFormSuccess('Изменения сохранены.');
await services.fetchBySlugAdmin(serviceId);
navigate('/admin/dashboard/services');
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось сохранить изменения. Попробуйте позже.';
setFormError(message);
} finally {
setIsSubmitting(false);
}
};
if (!serviceId) {
return (
<Alert severity="error">
Не указан идентификатор услуги. Вернитесь к списку и попробуйте снова.
</Alert>
);
}
if (isFetching && !currentService) {
return (
<Stack spacing={2} alignItems="center">
<CircularProgress size={32} />
<Typography>Загружаем данные услуги...</Typography>
</Stack>
);
}
if (fetchError && !currentService) {
return (
<Stack spacing={2}>
<Alert severity="error">{fetchError}</Alert>
<Button variant="outlined" onClick={() => navigate('/admin/dashboard/services')}>
Вернуться к списку
</Button>
</Stack>
);
}
if (!currentService) {
return (
<Alert severity="warning">
Услуга не найдена. Возможно, она была удалена. Вернитесь к списку и попробуйте снова.
</Alert>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', md: 'center' }}>
<div>
<Typography variant="h5">Редактирование услуги</Typography>
<Typography color="text.secondary">{currentService.title}</Typography>
</div>
<Button variant="outlined" size="small" onClick={() => navigate(-1)}>
Назад
</Button>
</Stack>
{formError && <Alert severity="error">{formError}</Alert>}
{formSuccess && <Alert severity="success">{formSuccess}</Alert>}
<ServiceForm
mode="edit"
initialValues={initialValues}
submitLabel="Сохранить изменения"
isSubmitting={isSubmitting}
onSubmit={handleSubmit}
onCancel={() => navigate('/admin/dashboard/services')}
/>
</Box>
);
};

View File

@@ -0,0 +1,57 @@
import type {
AdminServiceCreatePayload,
AdminServiceUpdatePayload,
} from '../../../../api';
import type { ServiceItem } from '../../../../api/services/types.ts';
export type ServiceFormValues = {
title: string;
slug: string;
description: string;
priceFrom: string;
imageUrl: string;
status: string;
categoryId: string;
};
export const defaultServiceFormValues: ServiceFormValues = {
title: '',
slug: '',
description: '',
priceFrom: '',
imageUrl: '',
status: 'PUBLISHED',
categoryId: '',
};
export const createServiceFormValues = (service?: Partial<ServiceItem>): ServiceFormValues => ({
title: service?.title ?? '',
slug: service?.slug ?? '',
description: service?.description ?? '',
priceFrom:
service?.priceFrom !== undefined && service?.priceFrom !== null ? String(service.priceFrom) : '',
imageUrl: service?.imageUrl ?? '',
status: service?.status ?? 'PUBLISHED',
categoryId:
service?.category?.id !== undefined && service?.category?.id !== null ? String(service.category.id) : '',
});
export const mapServiceFormToCreatePayload = (values: ServiceFormValues): AdminServiceCreatePayload => ({
title: values.title.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
priceFrom: values.priceFrom.trim() || undefined,
imageUrl: values.imageUrl.trim() || undefined,
status: values.status.trim() || undefined,
categoryId: values.categoryId ? Number(values.categoryId) : undefined,
});
export const mapServiceFormToUpdatePayload = (values: ServiceFormValues): AdminServiceUpdatePayload => ({
title: values.title.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
priceFrom: values.priceFrom.trim() || undefined,
imageUrl: values.imageUrl.trim() || undefined,
status: values.status.trim() || undefined,
categoryId: values.categoryId ? Number(values.categoryId) : undefined,
});

View File

@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import {
adminApi,
type AdminServiceCategory,
} from '../../../../api';
import { ApiError } from '../../../../api/httpClient.ts';
import { defaultServiceFormValues, type ServiceFormValues } from './service-form.helpers.ts';
const SERVICE_STATUSES = [
{ value: 'PUBLISHED', label: 'Опубликовано' },
{ value: 'DRAFT', label: 'Черновик' },
{ value: 'ARCHIVED', label: 'Архив' },
];
type FieldErrors = Partial<Record<keyof ServiceFormValues, string>>;
export interface ServiceFormProps {
mode: 'create' | 'edit';
initialValues?: ServiceFormValues;
submitLabel: string;
onSubmit: (values: ServiceFormValues) => Promise<void>;
onCancel: () => void;
isSubmitting: boolean;
}
export const ServiceForm: React.FC<ServiceFormProps> = ({
mode,
initialValues,
submitLabel,
onSubmit,
onCancel,
isSubmitting,
}) => {
const [form, setForm] = useState<ServiceFormValues>(initialValues ?? defaultServiceFormValues);
const [errors, setErrors] = useState<FieldErrors>({});
const [categories, setCategories] = useState<AdminServiceCategory[]>([]);
const [categoriesError, setCategoriesError] = useState<string | null>(null);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const initialValuesKey = useMemo(() => JSON.stringify(initialValues ?? defaultServiceFormValues), [initialValues]);
useEffect(() => {
if (!initialValues) {
setForm(defaultServiceFormValues);
return;
}
setForm(initialValues);
}, [initialValuesKey, initialValues]);
useEffect(() => {
const loadCategories = async () => {
setIsLoadingCategories(true);
setCategoriesError(null);
try {
const payload = await adminApi.listServiceCategories();
setCategories(payload);
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить категории услуг.';
setCategoriesError(message);
} finally {
setIsLoadingCategories(false);
}
};
void loadCategories();
}, []);
const validate = (): boolean => {
const next: FieldErrors = {};
if (!form.title.trim()) {
next.title = 'Введите название услуги';
}
if (!form.slug.trim()) {
next.slug = 'Введите slug услуги';
}
if (!form.description.trim()) {
next.description = 'Введите описание услуги';
}
if (form.priceFrom && Number.isNaN(Number(form.priceFrom))) {
next.priceFrom = 'Введите корректную стоимость';
}
if (form.imageUrl && !/^https?:\/\//i.test(form.imageUrl.trim())) {
next.imageUrl = 'Укажите ссылку, начинающуюся с http:// или https://';
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
if (errors[name as keyof ServiceFormValues]) {
setErrors((prev) => ({
...prev,
[name]: undefined,
}));
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!validate()) {
return;
}
await onSubmit(form);
};
const isCategorySelectDisabled = isLoadingCategories || categoriesError !== null || categories.length === 0;
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Название"
name="title"
value={form.title}
onChange={handleChange}
required
error={Boolean(errors.title)}
helperText={errors.title}
fullWidth
/>
<TextField
label="Slug"
name="slug"
value={form.slug}
onChange={handleChange}
required
error={Boolean(errors.slug)}
helperText={errors.slug ?? 'Используется в URL услуги'}
fullWidth
/>
<TextField
label="Описание"
name="description"
value={form.description}
onChange={handleChange}
required
error={Boolean(errors.description)}
helperText={errors.description}
multiline
minRows={4}
fullWidth
/>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
<TextField
label="Стоимость от"
name="priceFrom"
type="number"
value={form.priceFrom}
onChange={handleChange}
error={Boolean(errors.priceFrom)}
helperText={errors.priceFrom ?? 'Число в рублях, например 1500'}
inputProps={{ min: 0, step: '0.01' }}
fullWidth
/>
<TextField
select
label="Категория"
name="categoryId"
value={form.categoryId}
onChange={handleChange}
fullWidth
disabled={isCategorySelectDisabled}
helperText={
categoriesError
? categoriesError
: isLoadingCategories
? 'Загружаем категории...'
: 'Выберите категорию или оставьте пустым'
}
>
<MenuItem value="">Без категории</MenuItem>
{categories.map((category) => (
<MenuItem key={category.id} value={String(category.id)}>
{category.name}
</MenuItem>
))}
</TextField>
</Stack>
<TextField
label="Изображение (URL)"
name="imageUrl"
value={form.imageUrl}
onChange={handleChange}
error={Boolean(errors.imageUrl)}
helperText={errors.imageUrl ?? 'Необязательно'}
fullWidth
/>
<TextField
select
label="Статус"
name="status"
value={form.status}
onChange={handleChange}
fullWidth
helperText="Выберите статус публикации"
>
{SERVICE_STATUSES.map((status) => (
<MenuItem key={status.value} value={status.value}>
{status.label}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2} justifyContent="flex-end" flexWrap="wrap">
<Button variant="outlined" color="inherit" onClick={onCancel} disabled={isSubmitting}>
Отмена
</Button>
<Button type="submit" variant="contained" disabled={isSubmitting}>
{isSubmitting ? 'Сохраняем…' : submitLabel}
</Button>
</Stack>
{mode === 'edit' && (
<Typography variant="caption" color="text.secondary">
Все изменения сохраняются после нажатия «{submitLabel}».
</Typography>
)}
</Box>
);
};

View File

@@ -0,0 +1,5 @@
import { AdminDashboardLayout } from '../components/dashboard-layout.tsx';
export const AdminDashboardPage: React.FC = () => {
return <AdminDashboardLayout />;
};

View File

@@ -0,0 +1,5 @@
import { AdminLoginForm } from '../components/login-form.tsx';
export const AdminLoginPage: React.FC = () => {
return <AdminLoginForm />;
};

View File

@@ -0,0 +1,5 @@
import { AdminNewsCreateForm } from '../components/news/news-create-form.tsx';
export const AdminNewsCreatePage: React.FC = () => {
return <AdminNewsCreateForm />;
};

View File

@@ -0,0 +1,5 @@
import { AdminNewsEditForm } from '../components/news/news-edit-form.tsx';
export const AdminNewsEditPage: React.FC = () => {
return <AdminNewsEditForm />;
};

View File

@@ -0,0 +1,5 @@
import { AdminServiceCategoryCreateForm } from '../components/service-categories/service-category-create-form.tsx';
export const AdminServiceCategoryCreatePage: React.FC = () => {
return <AdminServiceCategoryCreateForm />;
};

View File

@@ -0,0 +1,5 @@
import { AdminServiceCategoryEditForm } from '../components/service-categories/service-category-edit-form.tsx';
export const AdminServiceCategoryEditPage: React.FC = () => {
return <AdminServiceCategoryEditForm />;
};

View File

@@ -0,0 +1,5 @@
import { AdminServiceCreateForm } from '../components/services/service-create-form.tsx';
export const AdminServiceCreatePage: React.FC = () => {
return <AdminServiceCreateForm />;
};

View File

@@ -0,0 +1,5 @@
import { AdminServiceEditForm } from '../components/services/service-edit-form.tsx';
export const AdminServiceEditPage: React.FC = () => {
return <AdminServiceEditForm />;
};

View File

@@ -0,0 +1,58 @@
import { Grid, Card, CardContent, Typography, Box, Container } from '@mui/material';
import SecurityIcon from '@mui/icons-material/Security';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import SearchIcon from '@mui/icons-material/Search';
import UpdateIcon from '@mui/icons-material/Update';
const features = [
{
icon: <SecurityIcon fontSize="large" color="primary" />,
title: 'Прозрачность и надёжность',
text: 'Все представленные данные основаны на официальных источниках и актуальных предложениях банков.',
},
{
icon: <AutoAwesomeIcon fontSize="large" color="primary" />,
title: 'Современные технологии',
text: 'Сайт создан с использованием современных веб-технологий для стабильной и быстрой работы.',
},
{
icon: <SearchIcon fontSize="large" color="primary" />,
title: 'Удобный поиск услуг',
text: 'Интерактивный каталог позволяет быстро находить нужные продукты по типу, ставке или сроку.',
},
{
icon: <UpdateIcon fontSize="large" color="primary" />,
title: 'Актуальная информация',
text: 'Информация регулярно обновляется, чтобы пользователи всегда получали свежие данные и выгодные предложения.',
},
];
export const Features = () => {
return (
<Container maxWidth="lg">
<Box sx={{ py: 8 }}>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Наши преимущества
</Typography>
<Grid container spacing={4} sx={{ pt: 5 }}>
{features.map((item, i) => (
<Grid key={i} sx={{ display: 'flex' }} size={6}>
<Card sx={{ height: '100%', textAlign: 'center', p: 2 }}>
<Box sx={{ mt: 2 }}>{item.icon}</Box>
<CardContent>
<Typography variant="h6" gutterBottom>
{item.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.text}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
</Container>
);
};

View File

@@ -0,0 +1,18 @@
import { Container, Box, Typography } from '@mui/material';
import { FeedbackForm } from './form.tsx';
export const Feedback = () => {
return (
<Container id="contacts">
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Свяжитесь с нами
</Typography>
<Typography component="h5" variant="h5" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Мы поможем разобраться в услугах и ответим на все вопросы.
</Typography>
<FeedbackForm />
</Box>
</Container>
);
};

View File

@@ -0,0 +1,156 @@
import { type ChangeEvent, type FormEvent, useId, useState } from 'react';
import Alert from '@mui/material/Alert';
import { Button, Stack, TextField } from '@mui/material';
import { leadsApi, type LeadCreatePayload } from '../../../api/index.ts';
import { ApiError } from '../../../api/httpClient.ts';
type FeedbackFormFields = {
fullName: string;
email: string;
phone: string;
};
type FeedbackFormErrors = Record<keyof FeedbackFormFields, string | null>;
const defaultFormState: FeedbackFormFields = {
fullName: '',
email: '',
phone: '',
};
const createDefaultErrors = (): FeedbackFormErrors => ({
fullName: null,
email: null,
phone: null,
});
export const FeedbackForm = () => {
const formInstanceId = useId();
const [form, setForm] = useState<FeedbackFormFields>(defaultFormState);
const [errors, setErrors] = useState<FeedbackFormErrors>(createDefaultErrors());
const [alert, setAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
setErrors((prev) => ({
...prev,
[name]: null,
}));
};
const validate = () => {
const nextErrors = createDefaultErrors();
let isValid = true;
if (!form.fullName.trim()) {
nextErrors.fullName = 'Укажите ваше имя';
isValid = false;
}
if (!form.email.trim()) {
nextErrors.email = 'Введите email';
isValid = false;
} else if (!/^[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}$/.test(form.email.trim())) {
nextErrors.email = 'Введите корректный email';
isValid = false;
}
if (form.phone.trim() && form.phone.trim().length < 5) {
nextErrors.phone = 'Введите телефон полностью';
isValid = false;
}
setErrors(nextErrors);
return isValid;
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setAlert(null);
if (!validate()) {
setAlert({ type: 'error', message: 'Пожалуйста, проверьте правильность заполнения формы.' });
return;
}
setIsSubmitting(true);
const normalizedPhone = form.phone.trim();
const payload: LeadCreatePayload = {
fullName: form.fullName.trim(),
email: form.email.trim(),
...(normalizedPhone ? { phone: normalizedPhone } : {}),
};
try {
await leadsApi.create(payload);
setAlert({ type: 'success', message: 'Спасибо! Мы свяжемся с вами в ближайшее время.' });
setForm(defaultFormState);
setErrors(createDefaultErrors());
} catch (err) {
const message =
err instanceof ApiError ? err.message || 'Не удалось отправить заявку. Попробуйте ещё раз.' : 'Не удалось отправить заявку. Попробуйте ещё раз.';
setAlert({ type: 'error', message });
} finally {
setIsSubmitting(false);
}
};
return (
<Stack component="form" onSubmit={handleSubmit} noValidate sx={{ alignItems: 'center', width: { xs: '100%', sm: '70%' } }} spacing={1.5}>
{alert && <Alert severity={alert.type}>{alert.message}</Alert>}
<TextField
id={`${formInstanceId}-fullName`}
name="fullName"
size="small"
variant="outlined"
placeholder="Иванов Иван"
value={form.fullName}
onChange={handleChange}
error={Boolean(errors.fullName)}
helperText={errors.fullName}
fullWidth
autoComplete="name"
/>
<Stack useFlexGap sx={{ alignItems: 'center', width: { xs: '100%' }, flexDirection: 'row' }} spacing={1}>
<TextField
id={`${formInstanceId}-email`}
name="email"
size="small"
variant="outlined"
placeholder="email@example.com"
value={form.email}
onChange={handleChange}
error={Boolean(errors.email)}
helperText={errors.email}
fullWidth
autoComplete="email"
/>
<TextField
id={`${formInstanceId}-phone`}
name="phone"
size="small"
variant="outlined"
placeholder="+7 999 123 45 67"
value={form.phone}
onChange={handleChange}
error={Boolean(errors.phone)}
helperText={errors.phone}
fullWidth
autoComplete="tel"
inputProps={{ inputMode: 'tel' }}
/>
</Stack>
<Button type="submit" variant="outlined" color="info" size="small" disabled={isSubmitting}>
{isSubmitting ? 'Отправляем...' : 'Оставить заявку'}
</Button>
</Stack>
);
};

View File

@@ -0,0 +1,52 @@
import { Typography, Stack, Box, Container } from '@mui/material';
import { FeedbackForm } from './form.tsx';
export const Hero = () => {
return (
<Box
id="hero"
sx={(theme) => ({
width: '100%',
backgroundRepeat: 'no-repeat',
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, hsl(210, 100%, 90%), transparent)',
...theme.applyStyles('dark', {
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, hsl(210, 100%, 16%), transparent)',
}),
})}
>
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: { xs: 14, sm: 20 },
pb: { xs: 8, sm: 12 },
}}
>
<Stack spacing={2} useFlexGap sx={{ alignItems: 'center', width: { xs: '100%', sm: '70%' } }}>
<Typography
variant="h1"
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: 'center',
fontSize: 'clamp(3rem, 10vw, 3.5rem)',
}}
>
Информационный портал о банковских услугах
</Typography>
<Typography
sx={{
textAlign: 'center',
color: 'text.secondary',
width: { sm: '100%', md: '80%' },
}}
>
Объединяем инновации и надёжность, чтобы финансовые решения стали простыми и доступными каждому.
</Typography>
<FeedbackForm />
</Stack>
</Container>
</Box>
);
};

View File

@@ -0,0 +1,102 @@
import { Container, Box, Typography, Grid, Card, CardContent, Stack } from '@mui/material';
import Logo1 from '../../../assets/partners/logo1.png';
import Logo2 from '../../../assets/partners/logo2.png';
import Logo3 from '../../../assets/partners/logo3.png';
import Logo4 from '../../../assets/partners/logo4.png';
import Logo5 from '../../../assets/partners/logo5.png';
import Logo6 from '../../../assets/partners/logo6.png';
import Logo7 from '../../../assets/partners/logo7.png';
import Logo8 from '../../../assets/partners/logo8.png';
import Logo9 from '../../../assets/partners/logo9.png';
import Logo10 from '../../../assets/partners/logo10.png';
export const Partners = () => {
return (
<Container>
<Box>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Наши Партнеры
</Typography>
<Stack direction="column" spacing={2} sx={{ width: '100%' }}>
<Grid container spacing={2}>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo1} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo2} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo3} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo4} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo5} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo6} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo7} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo8} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo9} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo10} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
</Grid>
</Stack>
</Box>
</Container>
);
};

View File

@@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { observer } from 'mobx-react-lite';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatDate, getDateValue } from '../../news/utils/formatDate.ts';
const RecentNewsComponent: React.FC = () => {
const { news } = useStore();
useEffect(() => {
const load = async () => {
try {
await news.fetch({ limit: 10 });
} catch {
/* errors surfaced via store state */
}
};
void load();
}, [news]);
const visibleNews = news.list
.slice()
.sort((a, b) => getDateValue(b.publishedAt) - getDateValue(a.publishedAt))
.slice(0, 3);
const hasNews = visibleNews.length > 0;
return (
<Container maxWidth="lg">
<Box sx={{ display: { xs: 'none', md: 'flex', width: '100%' }, mt: '2rem' }}>
<Stack direction="column" spacing={2} sx={{ width: '100%' }}>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Последние новости
</Typography>
{news.isLoading && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Загружаем последние новости...
</Typography>
)}
{news.error && (
<Typography sx={{ textAlign: 'center' }} color="error">
{news.error}
</Typography>
)}
{hasNews && (
<Grid container spacing={2}>
{visibleNews.map((item) => (
<Grid key={item.id} size={{ xs: 12, md: 6, lg: 4 }}>
<Card
key={item.id}
variant="outlined"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flexGrow: 1,
height: '100%',
}}
>
<CardHeader title={item.title} subheader={formatDate(item.publishedAt)} />
<CardContent>
<Typography gutterBottom>{item.summary}</Typography>
<Link href={item.href}>Читать полностью</Link>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
<Link href="/news" sx={{ alignSelf: 'center' }}>
Лента новостей
</Link>
</Stack>
</Box>
</Container>
);
};
export const RecentNews = observer(RecentNewsComponent);

View File

@@ -0,0 +1,98 @@
import React, { useEffect } from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import { observer } from 'mobx-react-lite';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatPrice } from '../../services/utils/formatPrice.ts';
const ServicesComponent: React.FC = () => {
const { services } = useStore();
useEffect(() => {
const load = async () => {
try {
await services.fetch({ limit: 10 });
} catch {
/* handled via store state */
}
};
void load();
}, [services]);
const visibleServices = services.list
.slice()
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
const hasServices = visibleServices.length > 0;
return (
<Container maxWidth="lg">
<Box sx={{ display: 'flex', width: '100%' }}>
<Stack direction="column" spacing={2} sx={{ width: '100%' }}>
<Typography id="services_anchor" component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Наши услуги
</Typography>
{services.isLoading && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Загружаем услуги...
</Typography>
)}
{services.error && (
<Typography sx={{ textAlign: 'center' }} color="error">
{services.error}
</Typography>
)}
{hasServices && (
<Grid container spacing={2}>
{visibleServices.map((service) => (
<Grid key={service.id} size={{ xs: 12, md: 6, lg: 4 }}>
<Card
variant="outlined"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<CardHeader title={service.title} subheader={service.category?.name ?? 'Без категории'} sx={{ pb: 0 }} />
<CardContent>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{formatPrice(service.priceFrom)}
</Typography>
<Typography gutterBottom color="text.secondary">
{service.description}
</Typography>
<Link href={service.href}>Подробнее</Link>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{!services.isLoading && !services.error && !hasServices && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Услуги скоро появятся.
</Typography>
)}
<Link href={'/services'} sx={{ alignSelf: 'center' }}>
Все услуги
</Link>
</Stack>
</Box>
</Container>
);
};
export const Services = observer(ServicesComponent);

View File

@@ -0,0 +1,27 @@
import { Fragment } from 'react';
import { Header } from '../../../shared/components/header.tsx';
import { Hero } from '../components/hero.tsx';
import { Services } from '../components/services.tsx';
import { Features } from '../components/features.tsx';
import { Partners } from '../components/partners.tsx';
import { Feedback } from '../components/feedback.tsx';
import { Footer } from '../../../shared/components/footer.tsx';
import { RecentNews } from '../components/recent-news.tsx';
import { usePageTitle } from '../../../shared/hooks/usePageTitle.ts';
export const MainPage = () => {
usePageTitle('Главная');
return (
<Fragment>
<Header />
<Hero />
<Services />
<RecentNews />
<Features />
<Partners />
<Feedback />
<Footer />
</Fragment>
);
};

View File

@@ -0,0 +1,2 @@
export { NewsFeedPage } from './news-feed.tsx';
export { NewsDetailsPage } from './news-details.tsx';

View File

@@ -0,0 +1,110 @@
import { Fragment, useEffect } from 'react';
import Box from '@mui/material/Box';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import Container from '@mui/material/Container';
import Link from '@mui/material/Link';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { observer } from 'mobx-react-lite';
import { useParams } from 'react-router';
import { Header } from '../../../shared/components/header.tsx';
import { Footer } from '../../../shared/components/footer.tsx';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatDate } from '../utils/formatDate.ts';
import { usePageTitle } from '../../../shared/hooks/usePageTitle.ts';
const NewsDetailsComponent: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { news } = useStore();
const currentNews = slug ? news.getBySlug(slug) : undefined;
useEffect(() => {
if (!slug || currentNews) {
return;
}
const load = async () => {
try {
await news.fetchBySlug(slug);
} catch {
/* handled via store state */
}
};
void load();
}, [currentNews, news, slug]);
const resolvedNews = currentNews ?? (slug ? news.getBySlug(slug) : undefined);
const isLoading = news.isLoading && !resolvedNews;
const showError = news.error && !resolvedNews;
usePageTitle(resolvedNews?.title ? `Новость: ${resolvedNews.title}` : 'Новость');
return (
<Fragment>
<Header />
<Box component="main" sx={{ pt: { xs: 12, md: 18 }, pb: 6 }}>
<Container maxWidth="md">
<Stack spacing={3}>
<Breadcrumbs aria-label="breadcrumbs">
<Link underline="hover" color="inherit" href="/">
Главная
</Link>
<Link underline="hover" color="inherit" href="/news">
Новости
</Link>
<Typography color="text.primary">{resolvedNews?.title ?? 'Загрузка...'}</Typography>
</Breadcrumbs>
{isLoading && (
<Stack spacing={2}>
<Skeleton variant="text" height={60} />
<Skeleton variant="rectangular" height={200} />
<Skeleton variant="text" height={40} />
</Stack>
)}
{showError && (
<Typography color="error" sx={{ textAlign: 'center' }}>
{news.error}
</Typography>
)}
{!isLoading && !showError && resolvedNews && (
<Stack spacing={2}>
<Typography variant="h3" component="h1">
{resolvedNews.title}
</Typography>
<Typography variant="subtitle2" color="text.secondary">
{formatDate(resolvedNews.publishedAt)}
</Typography>
{resolvedNews.imageUrl ? (
<Box
component="img"
src={resolvedNews.imageUrl}
alt={resolvedNews.title}
sx={{ width: '100%', borderRadius: 2, objectFit: 'cover', maxHeight: { xs: 260, md: 420 } }}
/>
) : null}
<Typography variant="body1">{resolvedNews.summary}</Typography>
{resolvedNews.content && (
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
{resolvedNews.content}
</Typography>
)}
</Stack>
)}
{!isLoading && !showError && !resolvedNews && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Новость не найдена или была удалена.
</Typography>
)}
</Stack>
</Container>
</Box>
<Footer />
</Fragment>
);
};
export const NewsDetailsPage = observer(NewsDetailsComponent);

View File

@@ -0,0 +1,129 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Pagination from '@mui/material/Pagination';
import { observer } from 'mobx-react-lite';
import { Header } from '../../../shared/components/header.tsx';
import { Footer } from '../../../shared/components/footer.tsx';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatDate, getDateValue } from '../utils/formatDate.ts';
import { usePageTitle } from '../../../shared/hooks/usePageTitle.ts';
const NewsFeedComponent: React.FC = () => {
const { news } = useStore();
const [page, setPage] = useState(1);
const pageSize = 12;
useEffect(() => {
const load = async () => {
try {
await news.fetch({ limit: pageSize, page });
} catch {
/* handled via store state */
}
};
void load();
}, [news, page]);
usePageTitle('Новости');
const items = useMemo(
() => news.list.slice().sort((a, b) => getDateValue(b.publishedAt) - getDateValue(a.publishedAt)),
[news.list],
);
const hasItems = items.length > 0;
const totalPages =
news.limit && news.total ? Math.ceil(news.total / news.limit) : hasItems ? Math.max(page, 1) : 0;
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<Fragment>
<Header />
<Box component="main" sx={{ pt: { xs: 12, md: 18 }, pb: 6 }}>
<Container maxWidth="lg">
<Stack spacing={3}>
<Typography variant="h2" component="h1" sx={{ textAlign: 'center' }}>
Новости
</Typography>
{news.isLoading && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Загружаем новости...
</Typography>
)}
{news.error && (
<Typography sx={{ textAlign: 'center' }} color="error">
{news.error}
</Typography>
)}
{!news.isLoading && !news.error && !hasItems && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Новости пока не опубликованы.
</Typography>
)}
{hasItems && (
<Grid container spacing={2}>
{items.map((item) => (
<Grid key={item.id} size={12}>
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{item.imageUrl && (
<CardMedia
component="img"
height="220"
image={item.imageUrl}
alt={item.title}
sx={{ objectFit: 'cover' }}
/>
)}
<CardHeader
title={item.title}
subheader={formatDate(item.publishedAt)}
sx={{ pb: 0, '& .MuiCardHeader-title': { fontSize: '1.25rem' } }}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography paragraph color="text.secondary">
{item.summary}
</Typography>
<Link href={item.href}>Читать полностью</Link>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{hasItems && totalPages > 1 && (
<Pagination
page={page}
onChange={(_, value) => setPage(value)}
count={totalPages}
color="primary"
sx={{ alignSelf: 'center' }}
showFirstButton
showLastButton
/>
)}
</Stack>
</Container>
</Box>
<Footer />
</Fragment>
);
};
export const NewsFeedPage = observer(NewsFeedComponent);

View File

@@ -0,0 +1,16 @@
export const getDateValue = (value: string) => {
const time = new Date(value).getTime();
return Number.isNaN(time) ? 0 : time;
};
export const formatDate = (value: string) => {
const time = getDateValue(value);
return time === 0
? ''
: new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(time);
};

View File

@@ -0,0 +1,2 @@
export { ServicesListPage } from './services-list.tsx';
export { ServiceDetailsPage } from './service-details.tsx';

View File

@@ -0,0 +1,120 @@
import { Fragment, useEffect } from 'react';
import Box from '@mui/material/Box';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import CardMedia from '@mui/material/CardMedia';
import Container from '@mui/material/Container';
import Link from '@mui/material/Link';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { observer } from 'mobx-react-lite';
import { useParams } from 'react-router';
import { Header } from '../../../shared/components/header.tsx';
import { Footer } from '../../../shared/components/footer.tsx';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatPrice } from '../utils/formatPrice.ts';
import { formatDate } from '../../news/utils/formatDate.ts';
import { usePageTitle } from '../../../shared/hooks/usePageTitle.ts';
const ServiceDetailsComponent: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { services } = useStore();
const resolveService = () => {
if (!slug) {
return undefined;
}
return services.getBySlug(slug) ?? services.getById(slug);
};
const service = resolveService();
useEffect(() => {
if (!slug || service) {
return;
}
const load = async () => {
try {
await services.fetchBySlug(slug);
} catch {
/* handled via store */
}
};
void load();
}, [service, services, slug]);
const resolved = service ?? resolveService();
usePageTitle(resolved?.title ? `Услуга: ${resolved.title}` : 'Услуга');
const isLoading = services.isLoading && !resolved;
const showError = services.error && !resolved;
return (
<Fragment>
<Header />
<Box component="main" sx={{ pt: { xs: 12, md: 18 }, pb: 6 }}>
<Container maxWidth="md">
<Stack spacing={3}>
<Breadcrumbs aria-label="breadcrumbs">
<Link underline="hover" color="inherit" href="/">
Главная
</Link>
<Link underline="hover" color="inherit" href="/services">
Услуги
</Link>
<Typography color="text.primary">{resolved?.title ?? 'Загрузка...'}</Typography>
</Breadcrumbs>
{isLoading && (
<Stack spacing={2}>
<Skeleton variant="text" height={60} />
<Skeleton variant="rectangular" height={200} />
<Skeleton variant="text" height={80} />
</Stack>
)}
{showError && (
<Typography color="error" sx={{ textAlign: 'center' }}>
{services.error}
</Typography>
)}
{!isLoading && !showError && resolved && (
<Stack spacing={3}>
<Typography variant="h3" component="h1">
{resolved.title}
</Typography>
{resolved.imageUrl ? (
<CardMedia
component="img"
image={resolved.imageUrl}
alt={resolved.title}
sx={{ borderRadius: 2, maxHeight: 360, objectFit: 'cover' }}
/>
) : null}
<Stack spacing={1}>
<Typography variant="subtitle1">{resolved.category?.name ?? 'Без категории'}</Typography>
<Typography variant="subtitle1">Стоимость: {formatPrice(resolved.priceFrom)}</Typography>
<Typography variant="body2" color="text.secondary">
Обновлено: {formatDate(resolved.updatedAt)} | Создано: {formatDate(resolved.createdAt)}
</Typography>
</Stack>
<Typography variant="body1">{resolved.description}</Typography>
<Link href="/services" underline="hover">
Вернуться к списку услуг
</Link>
</Stack>
)}
{!isLoading && !showError && !resolved && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Услуга не найдена или была удалена.
</Typography>
)}
</Stack>
</Container>
</Box>
<Footer />
</Fragment>
);
};
export const ServiceDetailsPage = observer(ServiceDetailsComponent);

View File

@@ -0,0 +1,121 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Pagination from '@mui/material/Pagination';
import { observer } from 'mobx-react-lite';
import { Header } from '../../../shared/components/header.tsx';
import { Footer } from '../../../shared/components/footer.tsx';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatPrice } from '../utils/formatPrice.ts';
import { usePageTitle } from '../../../shared/hooks/usePageTitle.ts';
const ServicesListComponent: React.FC = () => {
usePageTitle('Услуги');
const { services } = useStore();
const [page, setPage] = useState(1);
const pageSize = 12;
useEffect(() => {
const load = async () => {
try {
await services.fetch({ limit: pageSize, page }, { replace: true });
} catch {
/* handled via store state */
}
};
void load();
}, [services, page]);
const items = useMemo(
() => services.list.slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[services.list],
);
const hasItems = items.length > 0;
const totalPages =
services.limit && services.total ? Math.ceil(services.total / services.limit) : hasItems ? Math.max(page, 1) : 0;
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<Fragment>
<Header />
<Box component="main" sx={{ pt: { xs: 12, md: 18 }, pb: 6 }}>
<Container maxWidth="lg">
<Stack spacing={3}>
<Typography variant="h2" component="h1" sx={{ textAlign: 'center' }}>
Услуги
</Typography>
{services.isLoading && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Загружаем услуги...
</Typography>
)}
{services.error && (
<Typography sx={{ textAlign: 'center' }} color="error">
{services.error}
</Typography>
)}
{!services.isLoading && !services.error && !hasItems && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Услуги пока не добавлены.
</Typography>
)}
{hasItems && (
<Grid container spacing={2}>
{items.map((service) => (
<Grid key={service.id} size={{ xs: 12, md: 6, lg: 6 }}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }} variant="outlined">
{service.imageUrl ? (
<CardMedia component="img" image={service.imageUrl} alt={service.title} sx={{ maxHeight: 220, objectFit: 'cover' }} />
) : null}
<CardHeader title={service.title} subheader={service.category?.name ?? 'Без категории'} />
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" color="text.secondary">
{service.description}
</Typography>
<Typography variant="subtitle2">{formatPrice(service.priceFrom)}</Typography>
<Link href={service.href} sx={{ mt: 'auto', alignSelf: 'flex-start' }}>
Подробнее
</Link>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{hasItems && totalPages > 1 && (
<Pagination
page={page}
onChange={(_, value) => setPage(value)}
count={totalPages}
color="primary"
sx={{ alignSelf: 'center' }}
showFirstButton
showLastButton
/>
)}
</Stack>
</Container>
</Box>
<Footer />
</Fragment>
);
};
export const ServicesListPage = observer(ServicesListComponent);

View File

@@ -0,0 +1,7 @@
export const formatPrice = (value: number | null | undefined) => {
if (value === null || value === undefined) {
return 'Цена по запросу';
}
return `от ${value.toLocaleString('ru-RU')}`;
};

12
src/providers/store.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import { rootStore, type RootStoreType } from '../stores/root.tsx';
import * as React from 'react';
const StoreContext = createContext<RootStoreType>(rootStore);
// eslint-disable-next-line react-refresh/only-export-components
export const useRootStore = () => useContext(StoreContext);
export const StoreProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
return <StoreContext.Provider value={rootStore}>{children}</StoreContext.Provider>;
};

View File

@@ -0,0 +1,17 @@
import { Container, Box, Typography, Link } from '@mui/material';
export const Footer = () => {
return (
<Container>
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', mt: 2, pb: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
© 2025 БанкИнфо. Все права защищены. <br />
Информационный портал о банковских услугах. <br />
г. Москва, ул. Примерная, д. 5 | <Link href="mailto:info@bankinfo.ru">info@bankinfo.ru</Link> |{' '}
<Link href="tel:+7 (495) 000-00-00">+7 (495) 000-00-00</Link> <br />
Информация, размещённая на сайте, не является публичной офертой.
</Typography>
</Box>
</Container>
);
};

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { styled, alpha } from '@mui/material/styles';
import { Button, Link, Box, Toolbar, AppBar, Container } from '@mui/material';
import logo from '../../assets/logo.png';
const SiteLogo = () => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
width: '108px',
height: '36px',
overflow: 'hidden',
justifyItems: 'center',
background: `url(${logo})`,
backgroundSize: '108px 108px',
backgroundPosition: 'center center',
borderRadius: '15px',
}}
/>
);
};
const StyledToolbar = styled(Toolbar)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
borderRadius: `calc(${theme.shape.borderRadius}px + 8px)`,
backdropFilter: 'blur(24px)',
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
backgroundColor: theme.vars ? `rgba(${theme.vars.palette.background.defaultChannel} / 0.4)` : alpha(theme.palette.background.default, 0.4),
boxShadow: (theme.vars || theme).shadows[1],
padding: '8px 12px',
}));
export const Header: React.FC = () => {
return (
<AppBar
enableColorOnDark
position="fixed"
sx={{
boxShadow: 0,
bgcolor: 'transparent',
backgroundImage: 'none',
mt: 'calc(var(--template-frame-height, 0px) + 28px)',
}}
>
<Container maxWidth="lg">
<StyledToolbar disableGutters>
<Box sx={{ display: { xs: 'none', md: 'flex', width: '100%' } }}>
<SiteLogo />
<Box sx={{ display: { xs: 'none', md: 'flex', width: '100%' } }}>
<Button variant="text" color="info" size="small">
<Link href="/" underline="none">
Главная
</Link>
</Button>
<Button variant="text" color="info" size="small">
<Link underline="none" href="/services">
Услуги
</Link>
</Button>
<Button variant="text" color="info" size="small">
<Link href="/news" underline="none">
Новости
</Link>
</Button>
<Button variant="text" color="info" size="small">
<Link href="/about" underline="none">
О нас
</Link>
</Button>
<Button variant="text" color="info" size="small">
<Link href="/#contacts" underline="none">
Контакты
</Link>
</Button>
<Button variant="contained" color="info" size="small" sx={{ ml: 'auto' }}>
Оставить заявку
</Button>
</Box>
</Box>
</StyledToolbar>
</Container>
</AppBar>
);
};

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
const BASE_TITLE = 'Сбербанк';
export const usePageTitle = (title?: string) => {
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE;
}, [title]);
};

View File

@@ -0,0 +1 @@
export { useRootStore as useStore } from '../../providers/store.tsx'

View File

@@ -0,0 +1,89 @@
import React from 'react';
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
import LightModeIcon from '@mui/icons-material/LightModeRounded';
import Box from '@mui/material/Box';
import IconButton, { type IconButtonOwnProps } from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useColorScheme } from '@mui/material/styles';
export const ColorModeIconDropdown: React.FC<IconButtonOwnProps> = (props) => {
const { mode, systemMode, setMode } = useColorScheme();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => {
setMode(targetMode);
handleClose();
};
if (!mode) {
return (
<Box
data-screenshot="toggle-mode"
sx={(theme) => ({
verticalAlign: 'bottom',
display: 'inline-flex',
width: '2.25rem',
height: '2.25rem',
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
})}
/>
);
}
const resolvedMode = (systemMode || mode) as 'light' | 'dark';
const icon = {
light: <LightModeIcon />,
dark: <DarkModeIcon />,
}[resolvedMode];
return (
<React.Fragment>
<IconButton
data-screenshot="toggle-mode"
onClick={handleClick}
disableRipple
size="small"
aria-controls={open ? 'color-scheme-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
{...props}
>
{icon}
</IconButton>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
slotProps={{
paper: {
variant: 'outlined',
elevation: 0,
sx: {
my: '4px',
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
System
</MenuItem>
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
Light
</MenuItem>
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
Dark
</MenuItem>
</Menu>
</React.Fragment>
);
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useColorScheme } from '@mui/material/styles';
import MenuItem from '@mui/material/MenuItem';
import Select, { type SelectProps } from '@mui/material/Select';
export const ColorModeSelect: React.FC<SelectProps> = (props) => {
const { mode, setMode } = useColorScheme();
if (!mode) {
return null;
}
return (
<Select
value={mode}
onChange={(event) => setMode(event.target.value as 'system' | 'light' | 'dark')}
SelectDisplayProps={{
// @ts-expect-error MUI Docs used
'data-screenshot': 'toggle-mode',
}}
{...props}
>
<MenuItem value="system">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
);
};

View File

@@ -0,0 +1,232 @@
import { type Theme, alpha, type Components } from '@mui/material/styles';
import { svgIconClasses } from '@mui/material/SvgIcon';
import { typographyClasses } from '@mui/material/Typography';
import { buttonBaseClasses } from '@mui/material/ButtonBase';
import { chipClasses } from '@mui/material/Chip';
import { iconButtonClasses } from '@mui/material/IconButton';
import { gray, red, green } from '../themePrimitives.ts';
export const dataDisplayCustomizations: Components<Theme> = {
MuiList: {
styleOverrides: {
root: {
padding: '8px',
display: 'flex',
flexDirection: 'column',
gap: 0,
},
},
},
MuiListItem: {
styleOverrides: {
root: ({ theme }) => ({
[`& .${svgIconClasses.root}`]: {
width: '1rem',
height: '1rem',
color: (theme.vars || theme).palette.text.secondary,
},
[`& .${typographyClasses.root}`]: {
fontWeight: 500,
},
[`& .${buttonBaseClasses.root}`]: {
display: 'flex',
gap: 8,
padding: '2px 8px',
borderRadius: (theme.vars || theme).shape.borderRadius,
opacity: 0.7,
'&.Mui-selected': {
opacity: 1,
backgroundColor: alpha(theme.palette.action.selected, 0.3),
[`& .${svgIconClasses.root}`]: {
color: (theme.vars || theme).palette.text.primary,
},
'&:focus-visible': {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
'&:hover': {
backgroundColor: alpha(theme.palette.action.selected, 0.5),
},
},
'&:focus-visible': {
backgroundColor: 'transparent',
},
},
}),
},
},
MuiListItemText: {
styleOverrides: {
primary: ({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
fontWeight: 500,
lineHeight: theme.typography.body2.lineHeight,
}),
secondary: ({ theme }) => ({
fontSize: theme.typography.caption.fontSize,
lineHeight: theme.typography.caption.lineHeight,
}),
},
},
MuiListSubheader: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: 'transparent',
padding: '4px 8px',
fontSize: theme.typography.caption.fontSize,
fontWeight: 500,
lineHeight: theme.typography.caption.lineHeight,
}),
},
},
MuiListItemIcon: {
styleOverrides: {
root: {
minWidth: 0,
},
},
},
MuiChip: {
defaultProps: {
size: 'small',
},
styleOverrides: {
root: ({ theme }) => ({
border: '1px solid',
borderRadius: '999px',
[`& .${chipClasses.label}`]: {
fontWeight: 600,
},
variants: [
{
props: {
color: 'default',
},
style: {
borderColor: gray[200],
backgroundColor: gray[100],
[`& .${chipClasses.label}`]: {
color: gray[500],
},
[`& .${chipClasses.icon}`]: {
color: gray[500],
},
...theme.applyStyles('dark', {
borderColor: gray[700],
backgroundColor: gray[800],
[`& .${chipClasses.label}`]: {
color: gray[300],
},
[`& .${chipClasses.icon}`]: {
color: gray[300],
},
}),
},
},
{
props: {
color: 'success',
},
style: {
borderColor: green[200],
backgroundColor: green[50],
[`& .${chipClasses.label}`]: {
color: green[500],
},
[`& .${chipClasses.icon}`]: {
color: green[500],
},
...theme.applyStyles('dark', {
borderColor: green[800],
backgroundColor: green[900],
[`& .${chipClasses.label}`]: {
color: green[300],
},
[`& .${chipClasses.icon}`]: {
color: green[300],
},
}),
},
},
{
props: {
color: 'error',
},
style: {
borderColor: red[100],
backgroundColor: red[50],
[`& .${chipClasses.label}`]: {
color: red[500],
},
[`& .${chipClasses.icon}`]: {
color: red[500],
},
...theme.applyStyles('dark', {
borderColor: red[800],
backgroundColor: red[900],
[`& .${chipClasses.label}`]: {
color: red[200],
},
[`& .${chipClasses.icon}`]: {
color: red[300],
},
}),
},
},
{
props: { size: 'small' },
style: {
maxHeight: 20,
[`& .${chipClasses.label}`]: {
fontSize: theme.typography.caption.fontSize,
},
[`& .${svgIconClasses.root}`]: {
fontSize: theme.typography.caption.fontSize,
},
},
},
{
props: { size: 'medium' },
style: {
[`& .${chipClasses.label}`]: {
fontSize: theme.typography.caption.fontSize,
},
},
},
],
}),
},
},
MuiTablePagination: {
styleOverrides: {
actions: {
display: 'flex',
gap: 8,
marginRight: 6,
[`& .${iconButtonClasses.root}`]: {
minWidth: 0,
width: 36,
height: 36,
},
},
},
},
MuiIcon: {
defaultProps: {
fontSize: 'small',
},
styleOverrides: {
root: {
variants: [
{
props: {
fontSize: 'small',
},
style: {
fontSize: '1rem',
},
},
],
},
},
},
};

View File

@@ -0,0 +1,45 @@
import { type Theme, alpha, type Components } from '@mui/material/styles';
import { gray, orange } from '../themePrimitives.ts';
export const feedbackCustomizations: Components<Theme> = {
MuiAlert: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: 10,
backgroundColor: orange[100],
color: (theme.vars || theme).palette.text.primary,
border: `1px solid ${alpha(orange[300], 0.5)}`,
'& .MuiAlert-icon': {
color: orange[500],
},
...theme.applyStyles('dark', {
backgroundColor: `${alpha(orange[900], 0.5)}`,
border: `1px solid ${alpha(orange[800], 0.5)}`,
}),
}),
},
},
MuiDialog: {
styleOverrides: {
root: ({ theme }) => ({
'& .MuiDialog-paper': {
borderRadius: '10px',
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
},
}),
},
},
MuiLinearProgress: {
styleOverrides: {
root: ({ theme }) => ({
height: 8,
borderRadius: 8,
backgroundColor: gray[200],
...theme.applyStyles('dark', {
backgroundColor: gray[800],
}),
}),
},
},
};

View File

@@ -0,0 +1,449 @@
import { alpha, type Theme, type Components } from '@mui/material/styles';
import { outlinedInputClasses } from '@mui/material/OutlinedInput';
import { svgIconClasses } from '@mui/material/SvgIcon';
import { toggleButtonGroupClasses } from '@mui/material/ToggleButtonGroup';
import { toggleButtonClasses } from '@mui/material/ToggleButton';
import CheckBoxOutlineBlankRoundedIcon from '@mui/icons-material/CheckBoxOutlineBlankRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
import { gray, brand } from '../themePrimitives.ts';
export const inputsCustomizations: Components<Theme> = {
MuiButtonBase: {
defaultProps: {
disableTouchRipple: true,
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
boxSizing: 'border-box',
transition: 'all 100ms ease-in',
'&:focus-visible': {
outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
outlineOffset: '2px',
},
}),
},
},
MuiButton: {
styleOverrides: {
root: ({ theme }) => ({
boxShadow: 'none',
borderRadius: (theme.vars || theme).shape.borderRadius,
textTransform: 'none',
variants: [
{
props: {
size: 'small',
},
style: {
height: '2.25rem',
padding: '8px 12px',
},
},
{
props: {
size: 'medium',
},
style: {
height: '2.5rem', // 40px
},
},
{
props: {
color: 'primary',
variant: 'contained',
},
style: {
color: 'white',
backgroundColor: gray[900],
backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
border: `1px solid ${gray[700]}`,
'&:hover': {
backgroundImage: 'none',
backgroundColor: gray[700],
boxShadow: 'none',
},
'&:active': {
backgroundColor: gray[800],
},
...theme.applyStyles('dark', {
color: 'black',
backgroundColor: gray[50],
backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
border: `1px solid ${gray[50]}`,
'&:hover': {
backgroundImage: 'none',
backgroundColor: gray[300],
boxShadow: 'none',
},
'&:active': {
backgroundColor: gray[400],
},
}),
},
},
{
props: {
color: 'secondary',
variant: 'contained',
},
style: {
color: 'white',
backgroundColor: brand[300],
backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`,
boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
border: `1px solid ${brand[500]}`,
'&:hover': {
backgroundColor: brand[700],
boxShadow: 'none',
},
'&:active': {
backgroundColor: brand[700],
backgroundImage: 'none',
},
},
},
{
props: {
variant: 'outlined',
},
style: {
color: (theme.vars || theme).palette.text.primary,
border: '1px solid',
borderColor: gray[200],
backgroundColor: alpha(gray[50], 0.3),
'&:hover': {
backgroundColor: gray[100],
borderColor: gray[300],
},
'&:active': {
backgroundColor: gray[200],
},
...theme.applyStyles('dark', {
backgroundColor: gray[800],
borderColor: gray[700],
'&:hover': {
backgroundColor: gray[900],
borderColor: gray[600],
},
'&:active': {
backgroundColor: gray[900],
},
}),
},
},
{
props: {
color: 'secondary',
variant: 'outlined',
},
style: {
color: brand[700],
border: '1px solid',
borderColor: brand[200],
backgroundColor: brand[50],
'&:hover': {
backgroundColor: brand[100],
borderColor: brand[400],
},
'&:active': {
backgroundColor: alpha(brand[200], 0.7),
},
...theme.applyStyles('dark', {
color: brand[50],
border: '1px solid',
borderColor: brand[900],
backgroundColor: alpha(brand[900], 0.3),
'&:hover': {
borderColor: brand[700],
backgroundColor: alpha(brand[900], 0.6),
},
'&:active': {
backgroundColor: alpha(brand[900], 0.5),
},
}),
},
},
{
props: {
variant: 'text',
},
style: {
color: gray[600],
'&:hover': {
backgroundColor: gray[100],
},
'&:active': {
backgroundColor: gray[200],
},
...theme.applyStyles('dark', {
color: gray[50],
'&:hover': {
backgroundColor: gray[700],
},
'&:active': {
backgroundColor: alpha(gray[700], 0.7),
},
}),
},
},
{
props: {
color: 'secondary',
variant: 'text',
},
style: {
color: brand[700],
'&:hover': {
backgroundColor: alpha(brand[100], 0.5),
},
'&:active': {
backgroundColor: alpha(brand[200], 0.7),
},
...theme.applyStyles('dark', {
color: brand[100],
'&:hover': {
backgroundColor: alpha(brand[900], 0.5),
},
'&:active': {
backgroundColor: alpha(brand[900], 0.3),
},
}),
},
},
],
}),
},
},
MuiIconButton: {
styleOverrides: {
root: ({ theme }) => ({
boxShadow: 'none',
borderRadius: (theme.vars || theme).shape.borderRadius,
textTransform: 'none',
fontWeight: theme.typography.fontWeightMedium,
letterSpacing: 0,
color: (theme.vars || theme).palette.text.primary,
border: '1px solid ',
borderColor: gray[200],
backgroundColor: alpha(gray[50], 0.3),
'&:hover': {
backgroundColor: gray[100],
borderColor: gray[300],
},
'&:active': {
backgroundColor: gray[200],
},
...theme.applyStyles('dark', {
backgroundColor: gray[800],
borderColor: gray[700],
'&:hover': {
backgroundColor: gray[900],
borderColor: gray[600],
},
'&:active': {
backgroundColor: gray[900],
},
}),
variants: [
{
props: {
size: 'small',
},
style: {
width: '2.25rem',
height: '2.25rem',
padding: '0.25rem',
[`& .${svgIconClasses.root}`]: { fontSize: '1rem' },
},
},
{
props: {
size: 'medium',
},
style: {
width: '2.5rem',
height: '2.5rem',
},
},
],
}),
},
},
MuiToggleButtonGroup: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: '10px',
boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
[`& .${toggleButtonGroupClasses.selected}`]: {
color: brand[500],
},
...theme.applyStyles('dark', {
[`& .${toggleButtonGroupClasses.selected}`]: {
color: '#fff',
},
boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
}),
}),
},
},
MuiToggleButton: {
styleOverrides: {
root: ({ theme }) => ({
padding: '12px 16px',
textTransform: 'none',
borderRadius: '10px',
fontWeight: 500,
...theme.applyStyles('dark', {
color: gray[400],
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
[`&.${toggleButtonClasses.selected}`]: {
color: brand[300],
},
}),
}),
},
},
MuiCheckbox: {
defaultProps: {
disableRipple: true,
icon: <CheckBoxOutlineBlankRoundedIcon sx={{ color: 'hsla(210, 0%, 0%, 0.0)' }} />,
checkedIcon: <CheckRoundedIcon sx={{ height: 14, width: 14 }} />,
indeterminateIcon: <RemoveRoundedIcon sx={{ height: 14, width: 14 }} />,
},
styleOverrides: {
root: ({ theme }) => ({
margin: 10,
height: 16,
width: 16,
borderRadius: 5,
border: '1px solid ',
borderColor: alpha(gray[300], 0.8),
boxShadow: '0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset',
backgroundColor: alpha(gray[100], 0.4),
transition: 'border-color, background-color, 120ms ease-in',
'&:hover': {
borderColor: brand[300],
},
'&.Mui-focusVisible': {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: '2px',
borderColor: brand[400],
},
'&.Mui-checked': {
color: 'white',
backgroundColor: brand[500],
borderColor: brand[500],
boxShadow: `none`,
'&:hover': {
backgroundColor: brand[600],
},
},
...theme.applyStyles('dark', {
borderColor: alpha(gray[700], 0.8),
boxShadow: '0 0 0 1.5px hsl(210, 0%, 0%) inset',
backgroundColor: alpha(gray[900], 0.8),
'&:hover': {
borderColor: brand[300],
},
'&.Mui-focusVisible': {
borderColor: brand[400],
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: '2px',
},
}),
}),
},
},
MuiInputBase: {
styleOverrides: {
root: {
border: 'none',
},
input: {
'&::placeholder': {
opacity: 0.7,
color: gray[500],
},
},
},
},
MuiOutlinedInput: {
styleOverrides: {
input: {
padding: 0,
},
root: ({ theme }) => ({
padding: '8px 12px',
color: (theme.vars || theme).palette.text.primary,
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
backgroundColor: (theme.vars || theme).palette.background.default,
transition: 'border 120ms ease-in',
'&:hover': {
borderColor: gray[400],
},
[`&.${outlinedInputClasses.focused}`]: {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
borderColor: brand[400],
},
'&.MuiInputBase-multiline': {
padding: 0,
height: 'auto',
alignItems: 'flex-start',
'& textarea': {
padding: '12px',
},
},
...theme.applyStyles('dark', {
'&:hover': {
borderColor: gray[500],
},
}),
variants: [
{
props: {
size: 'small',
},
style: {
height: '2.25rem',
},
},
{
props: {
size: 'medium',
},
style: {
height: '2.5rem',
},
},
],
}),
notchedOutline: {
border: 'none',
},
},
},
MuiInputAdornment: {
styleOverrides: {
root: ({ theme }) => ({
color: (theme.vars || theme).palette.grey[500],
...theme.applyStyles('dark', {
color: (theme.vars || theme).palette.grey[400],
}),
}),
},
},
MuiFormLabel: {
styleOverrides: {
root: ({ theme }) => ({
typography: theme.typography.caption,
marginBottom: 8,
}),
},
},
};

View File

@@ -0,0 +1,274 @@
import * as React from 'react';
import { type Theme, alpha, type Components } from '@mui/material/styles';
import type { SvgIconProps } from '@mui/material/SvgIcon';
import { buttonBaseClasses } from '@mui/material/ButtonBase';
import { dividerClasses } from '@mui/material/Divider';
import { menuItemClasses } from '@mui/material/MenuItem';
import { selectClasses } from '@mui/material/Select';
import { tabClasses } from '@mui/material/Tab';
import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
import { gray, brand } from '../themePrimitives.ts';
export const navigationCustomizations: Components<Theme> = {
MuiMenuItem: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius,
padding: '6px 8px',
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: 'transparent',
},
[`&.${menuItemClasses.selected}`]: {
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
},
}),
},
},
MuiMenu: {
styleOverrides: {
list: {
gap: '0px',
[`&.${dividerClasses.root}`]: {
margin: '0 -8px',
},
},
paper: ({ theme }) => ({
marginTop: '4px',
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
backgroundImage: 'none',
background: 'hsl(0, 0%, 100%)',
boxShadow: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
[`& .${buttonBaseClasses.root}`]: {
'&.Mui-selected': {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
},
...theme.applyStyles('dark', {
background: gray[900],
boxShadow: 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
}),
}),
},
},
MuiSelect: {
defaultProps: {
IconComponent: React.forwardRef<SVGSVGElement, SvgIconProps>((props, ref) => <UnfoldMoreRoundedIcon fontSize="small" {...props} ref={ref} />),
},
styleOverrides: {
root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: gray[200],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
'&:hover': {
borderColor: gray[300],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none',
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: gray[400],
},
'&:before, &:after': {
display: 'none',
},
...theme.applyStyles('dark', {
borderRadius: (theme.vars || theme).shape.borderRadius,
borderColor: gray[700],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
'&:hover': {
borderColor: alpha(gray[700], 0.7),
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none',
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: gray[900],
},
'&:before, &:after': {
display: 'none',
},
}),
}),
select: ({ theme }) => ({
display: 'flex',
alignItems: 'center',
...theme.applyStyles('dark', {
display: 'flex',
alignItems: 'center',
'&:focus-visible': {
backgroundColor: gray[900],
},
}),
}),
},
},
MuiLink: {
defaultProps: {
underline: 'none',
},
styleOverrides: {
root: ({ theme }) => ({
color: (theme.vars || theme).palette.text.primary,
fontWeight: 500,
position: 'relative',
textDecoration: 'none',
width: 'fit-content',
'&::before': {
content: '""',
position: 'absolute',
width: '100%',
height: '1px',
bottom: 0,
left: 0,
backgroundColor: (theme.vars || theme).palette.text.secondary,
opacity: 0.3,
transition: 'width 0.3s ease, opacity 0.3s ease',
},
'&:hover::before': {
width: 0,
},
'&:focus-visible': {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: '4px',
borderRadius: '2px',
},
}),
},
},
MuiDrawer: {
styleOverrides: {
paper: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.background.default,
}),
},
},
MuiPaginationItem: {
styleOverrides: {
root: ({ theme }) => ({
'&.Mui-selected': {
color: 'white',
backgroundColor: (theme.vars || theme).palette.grey[900],
},
...theme.applyStyles('dark', {
'&.Mui-selected': {
color: 'black',
backgroundColor: (theme.vars || theme).palette.grey[50],
},
}),
}),
},
},
MuiTabs: {
styleOverrides: {
root: { minHeight: 'fit-content' },
indicator: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.grey[800],
...theme.applyStyles('dark', {
backgroundColor: (theme.vars || theme).palette.grey[200],
}),
}),
},
},
MuiTab: {
styleOverrides: {
root: ({ theme }) => ({
padding: '6px 8px',
marginBottom: '8px',
textTransform: 'none',
minWidth: 'fit-content',
minHeight: 'fit-content',
color: (theme.vars || theme).palette.text.secondary,
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: 'transparent',
':hover': {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: gray[100],
borderColor: gray[200],
},
[`&.${tabClasses.selected}`]: {
color: gray[900],
},
...theme.applyStyles('dark', {
':hover': {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: gray[800],
borderColor: gray[700],
},
[`&.${tabClasses.selected}`]: {
color: '#fff',
},
}),
}),
},
},
MuiStepConnector: {
styleOverrides: {
line: ({ theme }) => ({
borderTop: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
flex: 1,
borderRadius: '99px',
}),
},
},
MuiStepIcon: {
styleOverrides: {
root: ({ theme }) => ({
color: 'transparent',
border: `1px solid ${gray[400]}`,
width: 12,
height: 12,
borderRadius: '50%',
'& text': {
display: 'none',
},
'&.Mui-active': {
border: 'none',
color: (theme.vars || theme).palette.primary.main,
},
'&.Mui-completed': {
border: 'none',
color: (theme.vars || theme).palette.success.main,
},
...theme.applyStyles('dark', {
border: `1px solid ${gray[700]}`,
'&.Mui-active': {
border: 'none',
color: (theme.vars || theme).palette.primary.light,
},
'&.Mui-completed': {
border: 'none',
color: (theme.vars || theme).palette.success.light,
},
}),
variants: [
{
props: { completed: true },
style: {
width: 12,
height: 12,
},
},
],
}),
},
},
MuiStepLabel: {
styleOverrides: {
label: ({ theme }) => ({
'&.Mui-completed': {
opacity: 0.6,
...theme.applyStyles('dark', { opacity: 0.5 }),
},
}),
},
},
};

View File

@@ -0,0 +1,112 @@
import { alpha, type Theme, type Components } from '@mui/material/styles';
import { gray } from '../themePrimitives.ts';
export const surfacesCustomizations: Components<Theme> = {
MuiAccordion: {
defaultProps: {
elevation: 0,
disableGutters: true,
},
styleOverrides: {
root: ({ theme }) => ({
padding: 4,
overflow: 'clip',
backgroundColor: (theme.vars || theme).palette.background.default,
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
':before': {
backgroundColor: 'transparent',
},
'&:not(:last-of-type)': {
borderBottom: 'none',
},
'&:first-of-type': {
borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
},
'&:last-of-type': {
borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
},
}),
},
},
MuiAccordionSummary: {
styleOverrides: {
root: ({ theme }) => ({
border: 'none',
borderRadius: 8,
'&:hover': { backgroundColor: gray[50] },
'&:focus-visible': { backgroundColor: 'transparent' },
...theme.applyStyles('dark', {
'&:hover': { backgroundColor: gray[800] },
}),
}),
},
},
MuiAccordionDetails: {
styleOverrides: {
root: { mb: 20, border: 'none' },
},
},
MuiPaper: {
defaultProps: {
elevation: 0,
},
},
MuiCard: {
styleOverrides: {
root: ({ theme }) => {
return {
padding: 16,
gap: 16,
transition: 'all 100ms ease',
backgroundColor: gray[50],
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: 'none',
...theme.applyStyles('dark', {
backgroundColor: gray[800],
}),
variants: [
{
props: {
variant: 'outlined',
},
style: {
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: 'none',
background: 'hsl(0, 0%, 100%)',
...theme.applyStyles('dark', {
background: alpha(gray[900], 0.4),
}),
},
},
],
};
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
padding: 0,
'&:last-child': { paddingBottom: 0 },
},
},
},
MuiCardHeader: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiCardActions: {
styleOverrides: {
root: {
padding: 0,
},
},
},
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import type { ThemeOptions } from '@mui/material/styles';
import { inputsCustomizations } from './customizations/inputs.tsx';
import { dataDisplayCustomizations } from './customizations/dataDisplay.tsx';
import { feedbackCustomizations } from './customizations/feedback.tsx';
import { navigationCustomizations } from './customizations/navigation.tsx';
import { surfacesCustomizations } from './customizations/surfaces.ts';
import { colorSchemes, typography, shadows, shape } from './themePrimitives.ts';
interface AppThemeProps {
children: React.ReactNode;
/**
* This is for the docs site. You can ignore it or remove it.
*/
disableCustomTheme?: boolean;
themeComponents?: ThemeOptions['components'];
}
export const AppTheme: React.FC<AppThemeProps> = (props) => {
const { children, disableCustomTheme, themeComponents } = props;
const theme = React.useMemo(() => {
return disableCustomTheme
? {}
: createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme',
cssVarPrefix: 'template',
},
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
typography,
shadows,
shape,
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
...themeComponents,
},
});
}, [disableCustomTheme, themeComponents]);
if (disableCustomTheme) {
return <React.Fragment>{children}</React.Fragment>;
}
return (
<ThemeProvider theme={theme} disableTransitionOnChange>
{children}
</ThemeProvider>
);
};

View File

@@ -0,0 +1,394 @@
import { createTheme, alpha, type PaletteMode, type Shadows } from '@mui/material/styles';
declare module '@mui/material/Paper' {
interface PaperPropsVariantOverrides {
highlighted: true;
}
}
declare module '@mui/material/styles' {
interface ColorRange {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
}
interface Palette {
baseShadow: string;
}
}
const defaultTheme = createTheme();
const customShadows: Shadows = [...defaultTheme.shadows];
export const brand = {
50: 'hsl(210, 100%, 95%)',
100: 'hsl(210, 100%, 92%)',
200: 'hsl(210, 100%, 80%)',
300: 'hsl(210, 100%, 65%)',
400: 'hsl(210, 98%, 48%)',
500: 'hsl(210, 98%, 42%)',
600: 'hsl(210, 98%, 55%)',
700: 'hsl(210, 100%, 35%)',
800: 'hsl(210, 100%, 16%)',
900: 'hsl(210, 100%, 21%)',
};
export const gray = {
50: 'hsl(220, 35%, 97%)',
100: 'hsl(220, 30%, 94%)',
200: 'hsl(220, 20%, 88%)',
300: 'hsl(220, 20%, 80%)',
400: 'hsl(220, 20%, 65%)',
500: 'hsl(220, 20%, 42%)',
600: 'hsl(220, 20%, 35%)',
700: 'hsl(220, 20%, 25%)',
800: 'hsl(220, 30%, 6%)',
900: 'hsl(220, 35%, 3%)',
};
export const green = {
50: 'hsl(120, 80%, 98%)',
100: 'hsl(120, 75%, 94%)',
200: 'hsl(120, 75%, 87%)',
300: 'hsl(120, 61%, 77%)',
400: 'hsl(120, 44%, 53%)',
500: 'hsl(120, 59%, 30%)',
600: 'hsl(120, 70%, 25%)',
700: 'hsl(120, 75%, 16%)',
800: 'hsl(120, 84%, 10%)',
900: 'hsl(120, 87%, 6%)',
};
export const orange = {
50: 'hsl(45, 100%, 97%)',
100: 'hsl(45, 92%, 90%)',
200: 'hsl(45, 94%, 80%)',
300: 'hsl(45, 90%, 65%)',
400: 'hsl(45, 90%, 40%)',
500: 'hsl(45, 90%, 35%)',
600: 'hsl(45, 91%, 25%)',
700: 'hsl(45, 94%, 20%)',
800: 'hsl(45, 95%, 16%)',
900: 'hsl(45, 93%, 12%)',
};
export const red = {
50: 'hsl(0, 100%, 97%)',
100: 'hsl(0, 92%, 90%)',
200: 'hsl(0, 94%, 80%)',
300: 'hsl(0, 90%, 65%)',
400: 'hsl(0, 90%, 40%)',
500: 'hsl(0, 90%, 30%)',
600: 'hsl(0, 91%, 25%)',
700: 'hsl(0, 94%, 18%)',
800: 'hsl(0, 95%, 12%)',
900: 'hsl(0, 93%, 6%)',
};
export const getDesignTokens = (mode: PaletteMode) => {
customShadows[1] =
mode === 'dark'
? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
return {
palette: {
mode,
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
...(mode === 'dark' && {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
}),
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
...(mode === 'dark' && {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
}),
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
...(mode === 'dark' && {
light: orange[400],
main: orange[500],
dark: orange[700],
}),
},
error: {
light: red[300],
main: red[400],
dark: red[800],
...(mode === 'dark' && {
light: red[400],
main: red[500],
dark: red[700],
}),
},
success: {
light: green[300],
main: green[400],
dark: green[800],
...(mode === 'dark' && {
light: green[400],
main: green[500],
dark: green[700],
}),
},
grey: {
...gray,
},
divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: {
default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }),
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }),
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
...(mode === 'dark' && {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
}),
},
},
typography: {
fontFamily: 'Inter, sans-serif',
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
},
shape: {
borderRadius: 8,
},
shadows: customShadows,
};
};
export const colorSchemes = {
light: {
palette: {
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
},
error: {
light: red[300],
main: red[400],
dark: red[800],
},
success: {
light: green[300],
main: green[400],
dark: green[800],
},
grey: {
...gray,
},
divider: alpha(gray[300], 0.4),
background: {
default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)',
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
},
baseShadow: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
},
},
dark: {
palette: {
primary: {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
},
info: {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
},
warning: {
light: orange[400],
main: orange[500],
dark: orange[700],
},
error: {
light: red[400],
main: red[500],
dark: red[700],
},
success: {
light: green[400],
main: green[500],
dark: green[700],
},
grey: {
...gray,
},
divider: alpha(gray[700], 0.6),
background: {
default: gray[900],
paper: 'hsl(220, 30%, 7%)',
},
text: {
primary: 'hsl(0, 0%, 100%)',
secondary: gray[400],
},
action: {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
},
baseShadow: 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
},
},
};
export const typography = {
fontFamily: 'Inter, sans-serif',
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
};
export const shape = {
borderRadius: 8,
};
// @ts-ignore
const defaultShadows: Shadows = ['none', 'var(--template-palette-baseShadow)', ...defaultTheme.shadows.slice(2)];
export const shadows = defaultShadows;

View File

@@ -0,0 +1,2 @@
export * from './serviceCategory.model.ts';
export * from './serviceCategoryCollection.model.ts';

Some files were not shown because too many files have changed in this diff Show More