Add docker
9
.dockerignore
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"printWidth":150,"tabWidth":2,"useTabs":false,"semi":true,"singleQuote":true,"trailingComma":"es5","bracketSpacing":true}
|
||||||
23
AGENTS.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>client</title>
|
<title>client</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
14
nginx.conf
Normal 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
14
package.json
@@ -10,6 +10,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1"
|
||||||
},
|
},
|
||||||
@@ -23,8 +32,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7",
|
||||||
|
"zod": "^4.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
postcss.config.js
Normal 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
|
After Width: | Height: | Size: 1.3 MiB |
175
src/api/admin/index.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +1,19 @@
|
|||||||
import { useState } from 'react'
|
import { RouterProvider } from 'react-router';
|
||||||
import reactLogo from '../assets/react.svg'
|
import './App.css';
|
||||||
import viteLogo from '/vite.svg'
|
import { router } from './router/routes.tsx';
|
||||||
import './App.css'
|
import { StoreProvider } from '../providers/store.tsx';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import { AppTheme } from '../shared/theme/theme.tsx';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<StoreProvider>
|
||||||
<div>
|
<AppTheme>
|
||||||
<a href="https://vite.dev" target="_blank">
|
<CssBaseline enableColorScheme />
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<RouterProvider router={router} />
|
||||||
</a>
|
</AppTheme>
|
||||||
<a href="https://react.dev" target="_blank">
|
</StoreProvider>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
67
src/app/router/routes.tsx
Normal 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
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/assets/partners/logo1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/assets/partners/logo10.png
Normal file
|
After Width: | Height: | Size: 818 KiB |
BIN
src/assets/partners/logo2.png
Normal file
|
After Width: | Height: | Size: 639 KiB |
BIN
src/assets/partners/logo3.png
Normal file
|
After Width: | Height: | Size: 716 KiB |
BIN
src/assets/partners/logo4.png
Normal file
|
After Width: | Height: | Size: 738 KiB |
BIN
src/assets/partners/logo5.png
Normal file
|
After Width: | Height: | Size: 608 KiB |
BIN
src/assets/partners/logo6.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
src/assets/partners/logo7.png
Normal file
|
After Width: | Height: | Size: 785 KiB |
BIN
src/assets/partners/logo8.png
Normal file
|
After Width: | Height: | Size: 775 KiB |
BIN
src/assets/partners/logo9.png
Normal file
|
After Width: | Height: | Size: 811 KiB |
@@ -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 |
@@ -1,68 +1,4 @@
|
|||||||
:root {
|
@import '@fontsource/roboto/300.css';
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
@import '@fontsource/roboto/400.css';
|
||||||
line-height: 1.5;
|
@import '@fontsource/roboto/500.css';
|
||||||
font-weight: 400;
|
@import '@fontsource/roboto/700.css';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
135
src/modules/about/pages/about.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
427
src/modules/admin/components/admins/dashboard-admins.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
149
src/modules/admin/components/dashboard-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
160
src/modules/admin/components/leads/dashboard-leads.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
143
src/modules/admin/components/login-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
src/modules/admin/components/news/dashboard-news.tsx
Normal 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);
|
||||||
111
src/modules/admin/components/news/news-create-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
158
src/modules/admin/components/news/news-edit-form.tsx
Normal 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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
170
src/modules/admin/components/services/dashboard-services.tsx
Normal 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);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
148
src/modules/admin/components/services/service-edit-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
231
src/modules/admin/components/services/service-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/dashboard.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminDashboardLayout } from '../components/dashboard-layout.tsx';
|
||||||
|
|
||||||
|
export const AdminDashboardPage: React.FC = () => {
|
||||||
|
return <AdminDashboardLayout />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/login.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminLoginForm } from '../components/login-form.tsx';
|
||||||
|
|
||||||
|
export const AdminLoginPage: React.FC = () => {
|
||||||
|
return <AdminLoginForm />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/news-create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminNewsCreateForm } from '../components/news/news-create-form.tsx';
|
||||||
|
|
||||||
|
export const AdminNewsCreatePage: React.FC = () => {
|
||||||
|
return <AdminNewsCreateForm />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/news-edit.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminNewsEditForm } from '../components/news/news-edit-form.tsx';
|
||||||
|
|
||||||
|
export const AdminNewsEditPage: React.FC = () => {
|
||||||
|
return <AdminNewsEditForm />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/service-category-create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminServiceCategoryCreateForm } from '../components/service-categories/service-category-create-form.tsx';
|
||||||
|
|
||||||
|
export const AdminServiceCategoryCreatePage: React.FC = () => {
|
||||||
|
return <AdminServiceCategoryCreateForm />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/service-category-edit.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminServiceCategoryEditForm } from '../components/service-categories/service-category-edit-form.tsx';
|
||||||
|
|
||||||
|
export const AdminServiceCategoryEditPage: React.FC = () => {
|
||||||
|
return <AdminServiceCategoryEditForm />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/service-create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminServiceCreateForm } from '../components/services/service-create-form.tsx';
|
||||||
|
|
||||||
|
export const AdminServiceCreatePage: React.FC = () => {
|
||||||
|
return <AdminServiceCreateForm />;
|
||||||
|
};
|
||||||
5
src/modules/admin/pages/service-edit.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminServiceEditForm } from '../components/services/service-edit-form.tsx';
|
||||||
|
|
||||||
|
export const AdminServiceEditPage: React.FC = () => {
|
||||||
|
return <AdminServiceEditForm />;
|
||||||
|
};
|
||||||
58
src/modules/main/components/features.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/modules/main/components/feedback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
156
src/modules/main/components/form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
src/modules/main/components/hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
src/modules/main/components/partners.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
90
src/modules/main/components/recent-news.tsx
Normal 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);
|
||||||
98
src/modules/main/components/services.tsx
Normal 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);
|
||||||
27
src/modules/main/pages/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
src/modules/news/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { NewsFeedPage } from './news-feed.tsx';
|
||||||
|
export { NewsDetailsPage } from './news-details.tsx';
|
||||||
110
src/modules/news/pages/news-details.tsx
Normal 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);
|
||||||
129
src/modules/news/pages/news-feed.tsx
Normal 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);
|
||||||
16
src/modules/news/utils/formatDate.ts
Normal 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);
|
||||||
|
};
|
||||||
2
src/modules/services/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ServicesListPage } from './services-list.tsx';
|
||||||
|
export { ServiceDetailsPage } from './service-details.tsx';
|
||||||
120
src/modules/services/pages/service-details.tsx
Normal 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);
|
||||||
121
src/modules/services/pages/services-list.tsx
Normal 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);
|
||||||
7
src/modules/services/utils/formatPrice.ts
Normal 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
@@ -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>;
|
||||||
|
};
|
||||||
17
src/shared/components/footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
92
src/shared/components/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
src/shared/hooks/usePageTitle.ts
Normal 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]);
|
||||||
|
};
|
||||||
1
src/shared/hooks/useStore.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useRootStore as useStore } from '../../providers/store.tsx'
|
||||||
89
src/shared/theme/ColorModeIconDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
src/shared/theme/ColorModeSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
232
src/shared/theme/customizations/dataDisplay.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
45
src/shared/theme/customizations/feedback.tsx
Normal 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],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
449
src/shared/theme/customizations/inputs.tsx
Normal 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
274
src/shared/theme/customizations/navigation.tsx
Normal 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 }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
112
src/shared/theme/customizations/surfaces.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
53
src/shared/theme/theme.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
394
src/shared/theme/themePrimitives.ts
Normal 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;
|
||||||
2
src/stores/admin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './serviceCategory.model.ts';
|
||||||
|
export * from './serviceCategoryCollection.model.ts';
|
||||||