diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2f404fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +.gitignore +npm-debug.log* +yarn-error.log* +Dockerfile +docker-compose.yml +docs diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1b8ac88 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4b88918 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{"printWidth":150,"tabWidth":2,"useTabs":false,"semi":true,"singleQuote":true,"trailingComma":"es5","bracketSpacing":true} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e74300 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2172aa3 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0fa4a0 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..63e3200 --- /dev/null +++ b/docs/architecture.md @@ -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` монтирует `` в StrictMode. +2. `src/app/App.tsx` собирает корневое дерево: `` → `` → `` → ``. +3. `src/app/router/routes.tsx` объявляет все маршруты, разделяя публичные страницы (`/`, `/news`, `/services`, `/about`) и защищённую зону `/admin/dashboard` с вложенными разделами. + +Благодаря `` внутри `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/`. +2. Опишите MobX‑модель + коллекцию в `src/stores/`, инициализируйте её в `RootStore`. +3. Подготовьте UI в отдельном модуле внутри `src/modules/` и подключите маршруты через `src/app/router/routes.tsx`. +4. При необходимости добавьте переиспользуемые компоненты/хуки в `src/shared`. + +Следование существующим паттернам гарантирует единообразие и упрощает поддержку сервиса. diff --git a/docs/part1-2-brief.md b/docs/part1-2-brief.md new file mode 100644 index 0000000..7c57922 --- /dev/null +++ b/docs/part1-2-brief.md @@ -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. + +С точки зрения инженерии ПО, проект демонстрирует высокий уровень проработки, следование современным практикам и архитектурным принципам, а также готовность к дальнейшему развитию и эксплуатационному использованию + diff --git a/docs/sitemap.svg b/docs/sitemap.svg new file mode 100644 index 0000000..2ea0520 --- /dev/null +++ b/docs/sitemap.svg @@ -0,0 +1,133 @@ + + + + + + + + + Карта сайта веб-приложения + + + + Публичный раздел + + + + + / + Главная + (основная) + + + + + + /services + Список услуг + + + + + /news + Лента новостей + + + + + /about + О компании + + + + + /contacts + Контакты / заявка + + + + + + /services/:slug + Детали услуги + + + + + /news/:slug + Новостная статья + + + + + + + + + + + + + + Админ-раздел + + + + /admin + Логин + + + + + /admin/dashboard + Панель управления + + + + + /admin/dashboard/news + Управление новостями + + + + + /admin/dashboard/services + Управление услугами + + + + + /admin/dashboard/service-categories + Категории услуг + + + + + /admin/dashboard/leads + Заявки + + + + + /admin/dashboard/users + Администраторы + + + + + + + + + + diff --git a/docs/structure.svg b/docs/structure.svg new file mode 100644 index 0000000..a9d534c --- /dev/null +++ b/docs/structure.svg @@ -0,0 +1,57 @@ + + + + + + + + + Поток данных в клиентском приложении (Компонент → Store → + API → Store → UI) + + + + Компонент React + (UI + обработчики) + + + + MobX Store + (управление + состоянием) + + + + API Client + (fetch + Zod + валидация) + + + + Store Update + (наблюдаемые + изменения) + + + + UI Re-render + (observer + компоненты) + + + действие / намерение + + fetch-запрос + + валидированный ответ + + событие изменения + + следующее взаимодействие + \ No newline at end of file diff --git a/index.html b/index.html index a16cb6b..82a382e 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + client diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..dc0c388 --- /dev/null +++ b/nginx.conf @@ -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"; + } +} diff --git a/package-lock.json b/package-lock.json index 27d2c87..04f6aea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,15 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource/roboto": "^5.2.8", + "@mui/icons-material": "^7.3.4", + "@mui/material": "^7.3.4", + "autoprefixer": "^10.4.21", + "mobx": "^6.13.4", + "mobx-react-lite": "^4.0.7", + "postcss-nested": "^7.0.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -21,16 +30,18 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "prettier": "3.6.2", + "react-router-dom": "^7.9.4", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", - "vite": "^7.1.7" + "vite": "^7.1.7", + "zod": "^4.1.12" } }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -87,7 +98,6 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -121,7 +131,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -131,7 +140,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -173,7 +181,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -183,7 +190,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -217,7 +223,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.4" @@ -261,11 +266,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -280,7 +293,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -299,7 +311,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -309,6 +320,160 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -908,6 +1073,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource/roboto": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.8.tgz", + "integrity": "sha512-oh9g4Cg3loVMz9MWeKWfDI+ooxxG1aRVetkiKIb2ESS2rrryGecQ/y4pAj4z5A5ebyw450dYRi/c4k/I3UBhHA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -964,7 +1138,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -986,7 +1159,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -996,20 +1168,252 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz", + "integrity": "sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.4.tgz", + "integrity": "sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", + "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.4", + "@mui/system": "^7.3.3", + "@mui/types": "^7.4.7", + "@mui/utils": "^7.3.3", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.1.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.3.tgz", + "integrity": "sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.3.tgz", + "integrity": "sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz", + "integrity": "sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/private-theming": "^7.3.3", + "@mui/styled-engine": "^7.3.3", + "@mui/types": "^7.4.7", + "@mui/utils": "^7.3.3", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.7.tgz", + "integrity": "sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.3.tgz", + "integrity": "sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.7", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1048,6 +1452,16 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1433,11 +1847,22 @@ "undici-types": "~7.14.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1454,6 +1879,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", @@ -1811,6 +2245,58 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1822,7 +2308,6 @@ "version": "2.8.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -1856,7 +2341,6 @@ "version": "4.26.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1891,7 +2375,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1901,7 +2384,6 @@ "version": "1.0.30001750", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1935,6 +2417,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1969,6 +2460,41 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1984,18 +2510,28 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2016,13 +2552,31 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.235", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz", "integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==", - "dev": true, "license": "ISC" }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2069,7 +2623,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2079,7 +2632,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2354,6 +2906,12 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2392,6 +2950,19 @@ "dev": true, "license": "ISC" }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2407,6 +2978,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2460,6 +3040,33 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2474,7 +3081,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -2497,6 +3103,27 @@ "node": ">=0.8.19" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2541,7 +3168,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2561,7 +3187,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -2577,6 +3202,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2628,6 +3259,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2651,6 +3288,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2698,18 +3347,52 @@ "node": "*" } }, + "node_modules/mobx": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz", + "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react-lite": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz", + "integrity": "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^6.9.0", + "react": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2735,9 +3418,26 @@ "version": "2.0.23", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", - "dev": true, "license": "MIT" }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2792,7 +3492,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -2801,6 +3500,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2821,11 +3538,25 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2845,7 +3576,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2861,6 +3591,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2870,6 +3601,50 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2880,6 +3655,39 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2926,6 +3734,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2933,6 +3742,12 @@ "react": "^19.2.0" } }, + "node_modules/react-is": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2943,11 +3758,86 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", + "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-router": "7.9.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -3046,6 +3936,13 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3069,11 +3966,19 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3092,6 +3997,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3105,6 +4016,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3243,7 +4166,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3280,6 +4202,21 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.1.10", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", @@ -3433,6 +4370,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 69eaa2c..748abf0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource/roboto": "^5.2.8", + "@mui/icons-material": "^7.3.4", + "@mui/material": "^7.3.4", + "autoprefixer": "^10.4.21", + "mobx": "^6.13.4", + "mobx-react-lite": "^4.0.7", + "postcss-nested": "^7.0.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -23,8 +32,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "prettier": "3.6.2", + "react-router-dom": "^7.9.4", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", - "vite": "^7.1.7" + "vite": "^7.1.7", + "zod": "^4.1.12" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..ea64612 --- /dev/null +++ b/postcss.config.js @@ -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 \ No newline at end of file diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..8e045a2 Binary files /dev/null and b/public/logo.png differ diff --git a/src/api/admin/index.ts b/src/api/admin/index.ts new file mode 100644 index 0000000..a563863 --- /dev/null +++ b/src/api/admin/index.ts @@ -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 { + adminLoginPayloadSchema.parse(payload); + const response = await post(ADMIN_LOGIN_ENDPOINT, adminLoginResponseSchema, { + body: payload, + }); + + setAdminAuthToken(response.token); + return response; + }, + + async profile(): Promise { + return request(ADMIN_PROFILE_ENDPOINT, adminProfileSchema); + }, + + async registerAdmin(payload: AdminRegisterPayload): Promise { + adminRegisterPayloadSchema.parse(payload); + return post(ADMIN_PROFILE_ENDPOINT, adminRegisterResponseSchema, { + body: payload, + }); + }, + + async changePassword(adminId: string | number, payload: AdminPasswordChangePayload): Promise { + adminPasswordChangePayloadSchema.parse(payload); + return put(`${ADMIN_PROFILE_ENDPOINT}/${adminId}/password`, adminPasswordChangeResponseSchema, { + body: payload, + }); + }, + + async deleteAdmin(adminId: string | number): Promise { + return del(`${ADMIN_PROFILE_ENDPOINT}/${adminId}`, adminDeleteResponseSchema); + }, + + async listServiceCategories(): Promise { + return request(ADMIN_SERVICE_CATEGORIES_ENDPOINT, adminServiceCategoryListSchema); + }, + + async createServiceCategory(payload: AdminServiceCategoryCreatePayload): Promise { + const normalized = adminServiceCategoryCreatePayloadSchema.parse(payload); + return post(ADMIN_SERVICE_CATEGORIES_ENDPOINT, adminServiceCategoryCreateResponseSchema, { + body: normalized, + }); + }, + + async updateServiceCategory( + identifier: string | number, + payload: AdminServiceCategoryUpdatePayload, + ): Promise { + 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 { + const normalized = adminServiceCreatePayloadSchema.parse(payload); + return post(ADMIN_SERVICES_ENDPOINT, adminServiceCreateResponseSchema, { + body: normalized, + }); + }, + + async updateService(identifier: string | number, payload: AdminServiceUpdatePayload): Promise { + const normalized = adminServiceUpdatePayloadSchema.parse(payload); + return put(`${ADMIN_SERVICES_ENDPOINT}/${identifier}`, adminServiceUpdateResponseSchema, { + body: normalized, + }); + }, + + async getAdminService(identifier: string | number): Promise { + return request(`${ADMIN_SERVICES_ENDPOINT}/${identifier}`, serviceItemSchema); + }, + + async listNews(params?: ListNewsParams): Promise { + return request(ADMIN_NEWS_ENDPOINT, newsPageSchema, { + query: buildNewsQuery(params), + }); + }, + + async createNews(payload: AdminNewsCreatePayload): Promise { + adminNewsCreatePayloadSchema.parse(payload); + return post(ADMIN_NEWS_ENDPOINT, adminNewsCreateResponseSchema, { + body: payload, + }); + }, + + async updateNews(identifier: string, payload: AdminNewsUpdatePayload): Promise { + adminNewsUpdatePayloadSchema.parse(payload); + return put(`${ADMIN_NEWS_ENDPOINT}/${identifier}`, adminNewsUpdateResponseSchema, { + body: payload, + }); + }, + + logout() { + setAdminAuthToken(null); + }, +}; diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts new file mode 100644 index 0000000..21b4ca9 --- /dev/null +++ b/src/api/admin/types.ts @@ -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; + +export const adminLoginResponseSchema = z.object({ + token: z.string(), +}); + +export type AdminLoginResponse = z.infer; + +export const adminProfileSchema = z.object({ + id: z.union([z.string(), z.number()]), + username: z.string(), + createdAt: z.string(), +}); + +export type AdminProfile = z.infer; + +export const adminRegisterPayloadSchema = z.object({ + username: z.string().min(3), + password: z.string().min(8), +}); + +export type AdminRegisterPayload = z.infer; + +export const adminRegisterResponseSchema = z.object({ + id: z.union([z.string(), z.number()]), + username: z.string(), +}); + +export type AdminRegisterResponse = z.infer; + +export const adminPasswordChangePayloadSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8), +}); + +export type AdminPasswordChangePayload = z.infer; + +export const adminPasswordChangeResponseSchema = z.object({ + updated: z.boolean(), +}); + +export type AdminPasswordChangeResponse = z.infer; + +export const adminDeleteResponseSchema = z.object({ + deleted: z.boolean(), +}); + +export type AdminDeleteResponse = z.infer; + +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; + +export const adminServiceCreateResponseSchema = z.object({ + id: z.union([z.string(), z.number()]), +}); + +export type AdminServiceCreateResponse = z.infer; + +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; + +export const adminServiceUpdateResponseSchema = z.object({ + updated: z.boolean(), +}); + +export type AdminServiceUpdateResponse = z.infer; + +export const adminServiceCategorySchema = z.object({ + id: z.union([z.string(), z.number()]), + name: z.string(), + slug: z.string(), +}); + +export type AdminServiceCategory = z.infer; + +export const adminServiceCategoryListSchema = z.array(adminServiceCategorySchema); + +export type AdminServiceCategoryList = z.infer; + +export const adminServiceCategoryCreatePayloadSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1), +}); + +export type AdminServiceCategoryCreatePayload = z.infer; + +export const adminServiceCategoryCreateResponseSchema = z.object({ + id: z.union([z.string(), z.number()]), +}); + +export type AdminServiceCategoryCreateResponse = z.infer; + +export const adminServiceCategoryUpdatePayloadSchema = z.object({ + name: z.string().min(1).optional(), + slug: z.string().min(1).optional(), +}); + +export type AdminServiceCategoryUpdatePayload = z.infer; + +export const adminServiceCategoryUpdateResponseSchema = z.object({ + updated: z.boolean(), +}); + +export type AdminServiceCategoryUpdateResponse = z.infer; + +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; + +export const adminNewsCreateResponseSchema = z.object({ + id: z.union([z.string(), z.number()]), +}); + +export type AdminNewsCreateResponse = z.infer; + +export const adminNewsCreatePayloadSchema = adminNewsUpdatePayloadSchema.extend({ + status: adminNewsUpdatePayloadSchema.shape.status.default('draft'), +}); + +export type AdminNewsCreatePayload = z.infer; + +export const adminNewsPublishResponseSchema = z.object({ + published: z.boolean(), +}); + +export type AdminNewsPublishResponse = z.infer; + +export const adminNewsUpdateResponseSchema = z.object({ + updated: z.boolean(), +}); + +export type AdminNewsUpdateResponse = z.infer; diff --git a/src/api/httpClient.ts b/src/api/httpClient.ts new file mode 100644 index 0000000..490bf09 --- /dev/null +++ b/src/api/httpClient.ts @@ -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; + +export interface HttpRequestOptions extends Omit { + query?: RequestQuery; + body?: Record | 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, 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 ( + method: HttpMethod, + path: string, + schema: z.ZodType, + options?: HttpRequestOptions, +): Promise => { + 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 = { + 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).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 ( + path: string, + schema: z.ZodType, + options?: HttpRequestOptions, +) => send('GET', path, schema, options); + +export const post = async ( + path: string, + schema: z.ZodType, + options?: HttpRequestOptions, +) => send('POST', path, schema, options); + +export const put = async ( + path: string, + schema: z.ZodType, + options?: HttpRequestOptions, +) => send('PUT', path, schema, options); + +export const del = async ( + path: string, + schema: z.ZodType, + options?: HttpRequestOptions, +) => send('DELETE', path, schema, options); diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..12955b8 --- /dev/null +++ b/src/api/index.ts @@ -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'; diff --git a/src/api/leads/index.ts b/src/api/leads/index.ts new file mode 100644 index 0000000..888eefb --- /dev/null +++ b/src/api/leads/index.ts @@ -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 { + const normalized = leadCreatePayloadSchema.parse(payload); + return post(PUBLIC_LEADS_ENDPOINT, leadCreateResponseSchema, { + body: normalized, + }); + }, + + async list(params?: ListLeadsParams): Promise { + return request(ADMIN_LEADS_ENDPOINT, leadPageSchema, { + query: buildListQuery(params), + }); + }, + + async get(id: number): Promise { + return request(`${ADMIN_LEADS_ENDPOINT}/${id}`, leadSchema); + }, + + async remove(id: number): Promise { + return del(`${ADMIN_LEADS_ENDPOINT}/${id}`, leadDeleteResponseSchema); + }, +}; diff --git a/src/api/leads/types.ts b/src/api/leads/types.ts new file mode 100644 index 0000000..e1f623d --- /dev/null +++ b/src/api/leads/types.ts @@ -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; + +export const leadSchema = leadListItemSchema.extend({ + createdAt: z.string(), +}); + +export type Lead = z.infer; + +export const leadPageSchema = z.object({ + items: z.array(leadListItemSchema), + total: z.number(), + limit: z.number(), + offset: z.number(), +}); + +export type LeadPageResponse = z.infer; + +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; + +export const leadCreateResponseSchema = z.object({ + id: z.number(), +}); + +export type LeadCreateResponse = z.infer; + +export const leadDeleteResponseSchema = z.object({ + deleted: z.boolean(), +}); + +export type LeadDeleteResponse = z.infer; diff --git a/src/api/news/index.ts b/src/api/news/index.ts new file mode 100644 index 0000000..69c00df --- /dev/null +++ b/src/api/news/index.ts @@ -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 { + return request(NEWS_ENDPOINT, newsPageSchema, { + query: buildQuery(params), + }); + }, + async get(slug: string): Promise { + return request(`${NEWS_ENDPOINT}/${slug}`, newsItemSchema); + }, +}; diff --git a/src/api/news/types.ts b/src/api/news/types.ts new file mode 100644 index 0000000..4b6d30a --- /dev/null +++ b/src/api/news/types.ts @@ -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; + +export const newsListSchema = z.array(newsItemSchema); + +export type NewsListResponse = z.infer; + +export const newsPageSchema = z.object({ + items: newsListSchema, + total: z.number(), + limit: z.number(), + offset: z.number(), +}); + +export type NewsPageResponse = z.infer; diff --git a/src/api/services/index.ts b/src/api/services/index.ts new file mode 100644 index 0000000..63c35f4 --- /dev/null +++ b/src/api/services/index.ts @@ -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 { + return request(SERVICES_ENDPOINT, servicePageSchema, { + query: buildQuery(params), + }); + }, + async get(slug: string | number): Promise { + return request(`${SERVICES_ENDPOINT}/${slug}`, serviceItemSchema); + }, +}; diff --git a/src/api/services/types.ts b/src/api/services/types.ts new file mode 100644 index 0000000..d75afc9 --- /dev/null +++ b/src/api/services/types.ts @@ -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; +export type ServiceCategory = z.infer; + +export const serviceListSchema = z.array(serviceItemSchema); + +export type ServiceListResponse = z.infer; + +export const servicePageSchema = z.object({ + items: serviceListSchema, + total: z.number(), + limit: z.number(), + offset: z.number(), +}); + +export type ServicePageResponse = z.infer; diff --git a/src/app/App.css b/src/app/App.css index b9d355d..e69de29 100644 --- a/src/app/App.css +++ b/src/app/App.css @@ -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; -} diff --git a/src/app/App.tsx b/src/app/App.tsx index ce97400..429c0c8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,35 +1,19 @@ -import { useState } from 'react' -import reactLogo from '../assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { RouterProvider } from 'react-router'; +import './App.css'; +import { router } from './router/routes.tsx'; +import { StoreProvider } from '../providers/store.tsx'; +import CssBaseline from '@mui/material/CssBaseline'; +import { AppTheme } from '../shared/theme/theme.tsx'; function App() { - const [count, setCount] = useState(0) - return ( - <> - - - - - - - - - Vite + React - - setCount((count) => count + 1)}> - count is {count} - - - Edit src/App.tsx and save to test HMR - - - - Click on the Vite and React logos to learn more - - > - ) + + + + + + + ); } -export default App +export default App; diff --git a/src/app/router/routes.ts b/src/app/router/routes.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx new file mode 100644 index 0000000..afa3cae --- /dev/null +++ b/src/app/router/routes.tsx @@ -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: , + }, + { + path: '/news', + element: , + }, + { + path: '/news/:slug', + element: , + }, + { + path: '/about', + element: , + }, + { + path: '/services', + element: , + }, + { + path: '/services/:slug', + element: , + }, + { + path: '/admin', + element: , + }, + { + path: '/admin/dashboard', + element: , + children: [ + { index: true, element: }, + { path: 'news/new', element: }, + { path: 'news', element: }, + { path: 'news/:slug/edit', element: }, + { path: 'services/new', element: }, + { path: 'services', element: }, + { path: 'services/:serviceId/edit', element: }, + { path: 'categories/new', element: }, + { path: 'categories', element: }, + { path: 'categories/:categoryId/edit', element: }, + { path: 'leads', element: }, + { path: 'admins', element: }, + ], + }, +]); +import { AboutPage } from '../../modules/about/pages/about.tsx'; diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..8e045a2 Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/assets/partners/logo1.png b/src/assets/partners/logo1.png new file mode 100644 index 0000000..6ef3a82 Binary files /dev/null and b/src/assets/partners/logo1.png differ diff --git a/src/assets/partners/logo10.png b/src/assets/partners/logo10.png new file mode 100644 index 0000000..8442c56 Binary files /dev/null and b/src/assets/partners/logo10.png differ diff --git a/src/assets/partners/logo2.png b/src/assets/partners/logo2.png new file mode 100644 index 0000000..86244f4 Binary files /dev/null and b/src/assets/partners/logo2.png differ diff --git a/src/assets/partners/logo3.png b/src/assets/partners/logo3.png new file mode 100644 index 0000000..6ab88a9 Binary files /dev/null and b/src/assets/partners/logo3.png differ diff --git a/src/assets/partners/logo4.png b/src/assets/partners/logo4.png new file mode 100644 index 0000000..aa83a04 Binary files /dev/null and b/src/assets/partners/logo4.png differ diff --git a/src/assets/partners/logo5.png b/src/assets/partners/logo5.png new file mode 100644 index 0000000..944a6a8 Binary files /dev/null and b/src/assets/partners/logo5.png differ diff --git a/src/assets/partners/logo6.png b/src/assets/partners/logo6.png new file mode 100644 index 0000000..43d29a1 Binary files /dev/null and b/src/assets/partners/logo6.png differ diff --git a/src/assets/partners/logo7.png b/src/assets/partners/logo7.png new file mode 100644 index 0000000..2c5e3b5 Binary files /dev/null and b/src/assets/partners/logo7.png differ diff --git a/src/assets/partners/logo8.png b/src/assets/partners/logo8.png new file mode 100644 index 0000000..1549dc7 Binary files /dev/null and b/src/assets/partners/logo8.png differ diff --git a/src/assets/partners/logo9.png b/src/assets/partners/logo9.png new file mode 100644 index 0000000..830e25b Binary files /dev/null and b/src/assets/partners/logo9.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/index.css b/src/index.css index 08a3ac9..0b34c58 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,4 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@import '@fontsource/roboto/300.css'; +@import '@fontsource/roboto/400.css'; +@import '@fontsource/roboto/500.css'; +@import '@fontsource/roboto/700.css'; \ No newline at end of file diff --git a/src/modules/about/pages/about.tsx b/src/modules/about/pages/about.tsx new file mode 100644 index 0000000..d2751b7 --- /dev/null +++ b/src/modules/about/pages/about.tsx @@ -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 ( + + + + + + + + О компании «Сбербанк» + + + Крупнейший финансовый институт России и Восточной Европы, формирующий экосистему цифровых сервисов для миллионов клиентов. + + + + + {aboutFacts.map((fact) => ( + + + + + {fact.title} + + + {fact.value} + + + {fact.description} + + + + + ))} + + + + + + Финансовые показатели + + + {performanceMetrics.map((metric) => ( + + + {metric.label} + + {metric.value} + + {metric.note} + + + ))} + + + + + + + + Стратегия и направления роста + + + {strategicDirections.map((item) => ( + + {item} + + ))} + + + + + + + + Социальная роль + + + Сбербанк активно поддерживает предпринимателей и частных клиентов, инвестирует в инфраструктуру, образование и социальные проекты. + Особое внимание уделяется доступности финансовых услуг, цифровой безопасности и развитию регионов присутствия. + + + + + + + + + ); +}; diff --git a/src/modules/admin/components/admins/dashboard-admins.tsx b/src/modules/admin/components/admins/dashboard-admins.tsx new file mode 100644 index 0000000..bccde04 --- /dev/null +++ b/src/modules/admin/components/admins/dashboard-admins.tsx @@ -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(null); + const [isLoadingProfile, setIsLoadingProfile] = useState(true); + const [profileError, setProfileError] = useState(null); + + const [passwordForm, setPasswordForm] = useState(defaultPasswordForm); + const [passwordStatus, setPasswordStatus] = useState(defaultStatus); + + const [registerForm, setRegisterForm] = useState(defaultRegisterForm); + const [registerStatus, setRegisterStatus] = useState(defaultStatus); + + const [deleteStatus, setDeleteStatus] = useState(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) => { + 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) => { + 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 ( + + + Управление доступом + + Обновите пароль, удалите свой доступ или создайте учётную запись для коллеги. + + + + {isLoadingProfile && ( + + Загружаем профиль администратора... + + )} + + {profileError && {profileError}} + + {profile && ( + + + Текущий администратор + + + {profileDetails?.map((detail) => ( + + + {detail.label}: + + {detail.value} + + ))} + + + )} + + + + + + Смена пароля + + + Новый пароль должен содержать минимум 8 символов. Введите его дважды для подтверждения. + + + {passwordStatus.error && {passwordStatus.error}} + {passwordStatus.success && {passwordStatus.success}} + + setPasswordForm((prev) => ({ + ...prev, + current: event.target.value, + })) + } + required + autoComplete="current-password" + /> + + setPasswordForm((prev) => ({ + ...prev, + password: event.target.value, + })) + } + required + autoComplete="new-password" + /> + + setPasswordForm((prev) => ({ + ...prev, + confirm: event.target.value, + })) + } + required + autoComplete="new-password" + /> + + {passwordStatus.isSubmitting ? 'Обновляем...' : 'Обновить пароль'} + + + + + + + + + Регистрация нового администратора + + + Создайте дополнительный доступ для коллеги. Сохраните пароль и передайте его безопасным способом. + + + {registerStatus.error && {registerStatus.error}} + {registerStatus.success && {registerStatus.success}} + + setRegisterForm((prev) => ({ + ...prev, + username: event.target.value, + })) + } + required + autoComplete="username" + /> + + setRegisterForm((prev) => ({ + ...prev, + password: event.target.value, + })) + } + required + autoComplete="new-password" + /> + + setRegisterForm((prev) => ({ + ...prev, + confirm: event.target.value, + })) + } + required + autoComplete="new-password" + /> + + {registerStatus.isSubmitting ? 'Создаём...' : 'Создать администратора'} + + + + + + + + + Опасная зона + + + Полное удаление вашего аккаунта. После подтверждения доступ будет отозван, а вы будете перенаправлены к форме входа. + + {deleteStatus.error && ( + + {deleteStatus.error} + + )} + {deleteStatus.success && ( + + {deleteStatus.success} + + )} + + {deleteStatus.isSubmitting ? 'Удаляем...' : 'Удалить мой аккаунт'} + + + + ); +}; diff --git a/src/modules/admin/components/dashboard-layout.tsx b/src/modules/admin/components/dashboard-layout.tsx new file mode 100644 index 0000000..d362dad --- /dev/null +++ b/src/modules/admin/components/dashboard-layout.tsx @@ -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 = ( + + + + Панель управления + + + + + {sections.map((item) => ( + + setMobileOpen(false)} + > + + + + ))} + + + + + Выйти + + + + ); + + return ( + + + theme.palette.background.paper, + color: (theme) => theme.palette.text.primary, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + + + + + + {activeSection.label} + + + + + + {drawer} + + + {drawer} + + + + + + + + + + ); +}; diff --git a/src/modules/admin/components/leads/dashboard-leads.tsx b/src/modules/admin/components/leads/dashboard-leads.tsx new file mode 100644 index 0000000..6220572 --- /dev/null +++ b/src/modules/admin/components/leads/dashboard-leads.tsx @@ -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([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [limit, setLimit] = useState(pageSize); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( + + + Лиды и заявки + Просматривайте обращения клиентов и назначайте ответственных. + + + {isLoading && ( + + + + )} + + {error && {error}} + + {!isLoading && !error && items.length === 0 && ( + + Пока нет новых заявок. Как только клиенты оставят контакты, они появятся здесь. + + )} + + {!isLoading && !error && items.length > 0 && ( + + + + Дата и время + Имя + Email + Телефон + + + + {items.map((item) => ( + + {formatDateTime(item.createdAt ?? '')} + + {item.fullName} + + {item.email} + {item.phone ?? '—'} + + ))} + + + )} + + {!isLoading && !error && totalPages > 1 && ( + + setPage(value)} count={totalPages} color="primary" showFirstButton showLastButton /> + + )} + + ); +}; diff --git a/src/modules/admin/components/login-form.tsx b/src/modules/admin/components/login-form.tsx new file mode 100644 index 0000000..e43f3b3 --- /dev/null +++ b/src/modules/admin/components/login-form.tsx @@ -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(null); + const [success, setSuccess] = useState(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) => { + const { name, value } = event.target; + setForm((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = async (event: React.FormEvent) => { + 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 ( + theme.palette.grey[100], + py: 4, + }} + > + + + + + + Вход в админ-панель + + + Используйте учётные данные администратора + + + {error && {error}} + {isCheckingSession && !error && Проверяем активную сессию...} + {success && {success}} + + + + {isSubmitting ? 'Входим…' : 'Войти'} + + + + + + ); +}; diff --git a/src/modules/admin/components/news/dashboard-news.tsx b/src/modules/admin/components/news/dashboard-news.tsx new file mode 100644 index 0000000..15f231f --- /dev/null +++ b/src/modules/admin/components/news/dashboard-news.tsx @@ -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 ( + + + + Новости + Управляйте опубликованными материалами и следите за актуальностью. + + + Добавить новость + + + + {news.isLoading && ( + + + + )} + + {news.error && {news.error}} + + {!news.isLoading && !news.error && sortedNews.length === 0 && ( + + Ни одной новости пока не добавлено. + + )} + + {!news.isLoading && !news.error && sortedNews.length > 0 && ( + + + + Заголовок + Дата публикации + Краткое описание + Статус + + Действия + + + + + {sortedNews.map((item) => ( + + + {item.title} + + {item.slug} + + + {formatDate(item.publishedAt)} + + + {item.summary} + + + + + + + + handleEdit(item.slug)}> + + + + + handleDelete(item.id)}> + + + + + + ))} + + + )} + {!news.isLoading && !news.error && sortedNews.length > 0 && totalPages > 1 && ( + + setPage(value)} + count={totalPages} + color="primary" + showFirstButton + showLastButton + /> + + )} + + ); +}; + +export const AdminDashboardNews = observer(AdminDashboardNewsComponent); diff --git a/src/modules/admin/components/news/news-create-form.tsx b/src/modules/admin/components/news/news-create-form.tsx new file mode 100644 index 0000000..5c56c3d --- /dev/null +++ b/src/modules/admin/components/news/news-create-form.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setForm((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = async (event: React.FormEvent) => { + 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 ( + + + + Новая новость + Заполните данные и сохраните черновик или публикацию. + + navigate('/admin/dashboard/news')}> + Назад к списку + + + + {error && {error}} + {success && {success}} + + + + + + + + Черновик + Опубликовано + Архив + + + + {isSaving ? 'Сохраняем…' : 'Создать новость'} + + + + ); +}; diff --git a/src/modules/admin/components/news/news-edit-form.tsx b/src/modules/admin/components/news/news-edit-form.tsx new file mode 100644 index 0000000..4c037bb --- /dev/null +++ b/src/modules/admin/components/news/news-edit-form.tsx @@ -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(null); + const [success, setSuccess] = useState(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) => { + const { name, value } = event.target; + setForm((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = async (event: React.FormEvent) => { + 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 ( + + + + Редактирование новости + Обновите содержимое и сохраните изменения. + + navigate('/admin/dashboard/news')}> + Назад к списку + + + + {isLoadingEntry && ( + + + + )} + + {!isLoadingEntry && news.error && {news.error}} + + {!isLoadingEntry && !currentNews && !news.error && Новость не найдена. Возможно, она была удалена.} + + {!isLoadingEntry && currentNews && ( + + {error && {error}} + {success && {success}} + + + + + + + Черновик + Опубликовано + Архив + + + + {isSaving ? 'Сохраняем…' : 'Сохранить изменения'} + + + + )} + + ); +}; + +export const AdminNewsEditForm = observer(AdminNewsEditFormComponent); diff --git a/src/modules/admin/components/service-categories/dashboard-service-categories.tsx b/src/modules/admin/components/service-categories/dashboard-service-categories.tsx new file mode 100644 index 0000000..b61be7a --- /dev/null +++ b/src/modules/admin/components/service-categories/dashboard-service-categories.tsx @@ -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 ( + + + + Категории услуг + Создавайте и редактируйте категории для группировки услуг. + + + Добавить категорию + + + + {adminServiceCategories.isLoading && ( + + + + )} + + {adminServiceCategories.error && {adminServiceCategories.error}} + + {!adminServiceCategories.isLoading && !adminServiceCategories.error && sortedCategories.length === 0 && ( + + Категории пока не добавлены. + + )} + + {!adminServiceCategories.isLoading && !adminServiceCategories.error && sortedCategories.length > 0 && ( + + + + Название + Slug + + Действия + + + + + {sortedCategories.map((category) => ( + + + {category.name} + + + + {category.slug} + + + + + handleEdit(category.id)}> + + + + + handleDelete(category.id)}> + + + + + + ))} + + + )} + + ); +}; + +export const AdminDashboardServiceCategories = observer(AdminDashboardServiceCategoriesComponent); diff --git a/src/modules/admin/components/service-categories/service-category-create-form.tsx b/src/modules/admin/components/service-categories/service-category-create-form.tsx new file mode 100644 index 0000000..8ca500d --- /dev/null +++ b/src/modules/admin/components/service-categories/service-category-create-form.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( + + + + Новая категория + Введите название и slug категории. + + navigate(-1)}> + Назад + + + + {error && {error}} + {success && {success}} + + navigate('/admin/dashboard/categories')} + isSubmitting={isSubmitting} + /> + + ); +}; diff --git a/src/modules/admin/components/service-categories/service-category-edit-form.tsx b/src/modules/admin/components/service-categories/service-category-edit-form.tsx new file mode 100644 index 0000000..a4d4c1e --- /dev/null +++ b/src/modules/admin/components/service-categories/service-category-edit-form.tsx @@ -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(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + const [formSuccess, setFormSuccess] = useState(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 ( + + Не указан идентификатор категории. Вернитесь к списку и попробуйте снова. + + ); + } + + if (isFetching && !currentCategory) { + return ( + + + Загружаем категорию... + + ); + } + + if (fetchError && !currentCategory) { + return ( + + {fetchError} + navigate('/admin/dashboard/categories')}> + Вернуться к списку + + + ); + } + + if (!currentCategory) { + return ( + + Категория не найдена. Возможно, она была удалена. Вернитесь к списку. + + ); + } + + return ( + + + + Редактирование категории + {currentCategory.name} + + navigate(-1)}> + Назад + + + + {formError && {formError}} + {formSuccess && {formSuccess}} + + navigate('/admin/dashboard/categories')} + isSubmitting={isSubmitting} + /> + + ); +}; diff --git a/src/modules/admin/components/service-categories/service-category-form.helpers.ts b/src/modules/admin/components/service-categories/service-category-form.helpers.ts new file mode 100644 index 0000000..6c3a2af --- /dev/null +++ b/src/modules/admin/components/service-categories/service-category-form.helpers.ts @@ -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, +): 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(), +}); diff --git a/src/modules/admin/components/service-categories/service-category-form.tsx b/src/modules/admin/components/service-categories/service-category-form.tsx new file mode 100644 index 0000000..fd9cf76 --- /dev/null +++ b/src/modules/admin/components/service-categories/service-category-form.tsx @@ -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>; + +export interface ServiceCategoryFormProps { + initialValues?: ServiceCategoryFormValues; + submitLabel: string; + onSubmit: (values: ServiceCategoryFormValues) => Promise; + onCancel: () => void; + isSubmitting: boolean; +} + +export const ServiceCategoryForm: React.FC = ({ + initialValues, + submitLabel, + onSubmit, + onCancel, + isSubmitting, +}) => { + const [form, setForm] = useState(initialValues ?? defaultServiceCategoryFormValues); + const [errors, setErrors] = useState({}); + + 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) => { + 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) => { + event.preventDefault(); + if (!validate()) { + return; + } + await onSubmit(form); + }; + + return ( + + + + + + Отмена + + + {isSubmitting ? 'Сохраняем…' : submitLabel} + + + + ); +}; diff --git a/src/modules/admin/components/services/dashboard-services.tsx b/src/modules/admin/components/services/dashboard-services.tsx new file mode 100644 index 0000000..9b7d16a --- /dev/null +++ b/src/modules/admin/components/services/dashboard-services.tsx @@ -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 ( + + + + Услуги + Добавляйте новые услуги, обновляйте цены и статусы. + + + Добавить услугу + + + + {services.isLoading && ( + + + + )} + + {services.error && {services.error}} + + {!services.isLoading && !services.error && sortedServices.length === 0 && ( + + Услуги пока не добавлены. + + )} + + {!services.isLoading && !services.error && sortedServices.length > 0 && ( + + + + Название + Категория + Стоимость + Статус + + Действия + + + + + {sortedServices.map((service) => ( + + + {service.title} + + {service.slug || service.id} + + + {service.category?.name ?? 'Без категории'} + {formatPrice(service.priceFrom)} + + + + + + handleEdit(service.id)}> + + + + + handleDelete(service.id)}> + + + + + + ))} + + + )} + {!services.isLoading && !services.error && sortedServices.length > 0 && totalPages > 1 && ( + + setPage(value)} + count={totalPages} + color="primary" + showFirstButton + showLastButton + /> + + )} + + ); +}; + +export const AdminDashboardServices = observer(AdminDashboardServicesComponent); diff --git a/src/modules/admin/components/services/service-create-form.tsx b/src/modules/admin/components/services/service-create-form.tsx new file mode 100644 index 0000000..38ab45a --- /dev/null +++ b/src/modules/admin/components/services/service-create-form.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( + + + + Новая услуга + Заполните данные и сохраните новую услугу. + + navigate(-1)}> + Назад + + + + {error && {error}} + {success && {success}} + + navigate('/admin/dashboard/services')} + /> + + ); +}; diff --git a/src/modules/admin/components/services/service-edit-form.tsx b/src/modules/admin/components/services/service-edit-form.tsx new file mode 100644 index 0000000..5140282 --- /dev/null +++ b/src/modules/admin/components/services/service-edit-form.tsx @@ -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(null); + const [isFetching, setIsFetching] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + const [formSuccess, setFormSuccess] = useState(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 ( + + Не указан идентификатор услуги. Вернитесь к списку и попробуйте снова. + + ); + } + + if (isFetching && !currentService) { + return ( + + + Загружаем данные услуги... + + ); + } + + if (fetchError && !currentService) { + return ( + + {fetchError} + navigate('/admin/dashboard/services')}> + Вернуться к списку + + + ); + } + + if (!currentService) { + return ( + + Услуга не найдена. Возможно, она была удалена. Вернитесь к списку и попробуйте снова. + + ); + } + + return ( + + + + Редактирование услуги + {currentService.title} + + navigate(-1)}> + Назад + + + + {formError && {formError}} + {formSuccess && {formSuccess}} + + navigate('/admin/dashboard/services')} + /> + + ); +}; diff --git a/src/modules/admin/components/services/service-form.helpers.ts b/src/modules/admin/components/services/service-form.helpers.ts new file mode 100644 index 0000000..3f7cc59 --- /dev/null +++ b/src/modules/admin/components/services/service-form.helpers.ts @@ -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): 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, +}); diff --git a/src/modules/admin/components/services/service-form.tsx b/src/modules/admin/components/services/service-form.tsx new file mode 100644 index 0000000..5c934f9 --- /dev/null +++ b/src/modules/admin/components/services/service-form.tsx @@ -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>; + +export interface ServiceFormProps { + mode: 'create' | 'edit'; + initialValues?: ServiceFormValues; + submitLabel: string; + onSubmit: (values: ServiceFormValues) => Promise; + onCancel: () => void; + isSubmitting: boolean; +} + +export const ServiceForm: React.FC = ({ + mode, + initialValues, + submitLabel, + onSubmit, + onCancel, + isSubmitting, +}) => { + const [form, setForm] = useState(initialValues ?? defaultServiceFormValues); + const [errors, setErrors] = useState({}); + const [categories, setCategories] = useState([]); + const [categoriesError, setCategoriesError] = useState(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) => { + 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) => { + event.preventDefault(); + if (!validate()) { + return; + } + await onSubmit(form); + }; + + const isCategorySelectDisabled = isLoadingCategories || categoriesError !== null || categories.length === 0; + + return ( + + + + + + + + Без категории + {categories.map((category) => ( + + {category.name} + + ))} + + + + + {SERVICE_STATUSES.map((status) => ( + + {status.label} + + ))} + + + + Отмена + + + {isSubmitting ? 'Сохраняем…' : submitLabel} + + + {mode === 'edit' && ( + + Все изменения сохраняются после нажатия «{submitLabel}». + + )} + + ); +}; diff --git a/src/modules/admin/pages/dashboard.tsx b/src/modules/admin/pages/dashboard.tsx new file mode 100644 index 0000000..ba79299 --- /dev/null +++ b/src/modules/admin/pages/dashboard.tsx @@ -0,0 +1,5 @@ +import { AdminDashboardLayout } from '../components/dashboard-layout.tsx'; + +export const AdminDashboardPage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/login.tsx b/src/modules/admin/pages/login.tsx new file mode 100644 index 0000000..61ad45e --- /dev/null +++ b/src/modules/admin/pages/login.tsx @@ -0,0 +1,5 @@ +import { AdminLoginForm } from '../components/login-form.tsx'; + +export const AdminLoginPage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/news-create.tsx b/src/modules/admin/pages/news-create.tsx new file mode 100644 index 0000000..6fd4da6 --- /dev/null +++ b/src/modules/admin/pages/news-create.tsx @@ -0,0 +1,5 @@ +import { AdminNewsCreateForm } from '../components/news/news-create-form.tsx'; + +export const AdminNewsCreatePage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/news-edit.tsx b/src/modules/admin/pages/news-edit.tsx new file mode 100644 index 0000000..56fbe95 --- /dev/null +++ b/src/modules/admin/pages/news-edit.tsx @@ -0,0 +1,5 @@ +import { AdminNewsEditForm } from '../components/news/news-edit-form.tsx'; + +export const AdminNewsEditPage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/service-category-create.tsx b/src/modules/admin/pages/service-category-create.tsx new file mode 100644 index 0000000..30d51fc --- /dev/null +++ b/src/modules/admin/pages/service-category-create.tsx @@ -0,0 +1,5 @@ +import { AdminServiceCategoryCreateForm } from '../components/service-categories/service-category-create-form.tsx'; + +export const AdminServiceCategoryCreatePage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/service-category-edit.tsx b/src/modules/admin/pages/service-category-edit.tsx new file mode 100644 index 0000000..3ac0a4b --- /dev/null +++ b/src/modules/admin/pages/service-category-edit.tsx @@ -0,0 +1,5 @@ +import { AdminServiceCategoryEditForm } from '../components/service-categories/service-category-edit-form.tsx'; + +export const AdminServiceCategoryEditPage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/service-create.tsx b/src/modules/admin/pages/service-create.tsx new file mode 100644 index 0000000..a78230d --- /dev/null +++ b/src/modules/admin/pages/service-create.tsx @@ -0,0 +1,5 @@ +import { AdminServiceCreateForm } from '../components/services/service-create-form.tsx'; + +export const AdminServiceCreatePage: React.FC = () => { + return ; +}; diff --git a/src/modules/admin/pages/service-edit.tsx b/src/modules/admin/pages/service-edit.tsx new file mode 100644 index 0000000..21cd8d3 --- /dev/null +++ b/src/modules/admin/pages/service-edit.tsx @@ -0,0 +1,5 @@ +import { AdminServiceEditForm } from '../components/services/service-edit-form.tsx'; + +export const AdminServiceEditPage: React.FC = () => { + return ; +}; diff --git a/src/modules/main/components/features.tsx b/src/modules/main/components/features.tsx new file mode 100644 index 0000000..774339c --- /dev/null +++ b/src/modules/main/components/features.tsx @@ -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: , + title: 'Прозрачность и надёжность', + text: 'Все представленные данные основаны на официальных источниках и актуальных предложениях банков.', + }, + { + icon: , + title: 'Современные технологии', + text: 'Сайт создан с использованием современных веб-технологий для стабильной и быстрой работы.', + }, + { + icon: , + title: 'Удобный поиск услуг', + text: 'Интерактивный каталог позволяет быстро находить нужные продукты по типу, ставке или сроку.', + }, + { + icon: , + title: 'Актуальная информация', + text: 'Информация регулярно обновляется, чтобы пользователи всегда получали свежие данные и выгодные предложения.', + }, +]; + +export const Features = () => { + return ( + + + + Наши преимущества + + + {features.map((item, i) => ( + + + {item.icon} + + + {item.title} + + + {item.text} + + + + + ))} + + + + ); +}; diff --git a/src/modules/main/components/feedback.tsx b/src/modules/main/components/feedback.tsx new file mode 100644 index 0000000..fa670f2 --- /dev/null +++ b/src/modules/main/components/feedback.tsx @@ -0,0 +1,18 @@ +import { Container, Box, Typography } from '@mui/material'; +import { FeedbackForm } from './form.tsx'; + +export const Feedback = () => { + return ( + + + + Свяжитесь с нами + + + Мы поможем разобраться в услугах и ответим на все вопросы. + + + + + ); +}; diff --git a/src/modules/main/components/form.tsx b/src/modules/main/components/form.tsx new file mode 100644 index 0000000..7df7dd2 --- /dev/null +++ b/src/modules/main/components/form.tsx @@ -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; + +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(defaultFormState); + const [errors, setErrors] = useState(createDefaultErrors()); + const [alert, setAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleChange = (event: ChangeEvent) => { + 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) => { + 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 ( + + {alert && {alert.message}} + + + + + + + {isSubmitting ? 'Отправляем...' : 'Оставить заявку'} + + + ); +}; diff --git a/src/modules/main/components/hero.tsx b/src/modules/main/components/hero.tsx new file mode 100644 index 0000000..9885337 --- /dev/null +++ b/src/modules/main/components/hero.tsx @@ -0,0 +1,52 @@ +import { Typography, Stack, Box, Container } from '@mui/material'; +import { FeedbackForm } from './form.tsx'; + +export const Hero = () => { + return ( + ({ + 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)', + }), + })} + > + + + + Информационный портал о банковских услугах + + + Объединяем инновации и надёжность, чтобы финансовые решения стали простыми и доступными каждому. + + + + + + ); +}; diff --git a/src/modules/main/components/partners.tsx b/src/modules/main/components/partners.tsx new file mode 100644 index 0000000..b5b28aa --- /dev/null +++ b/src/modules/main/components/partners.tsx @@ -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 ( + + + + Наши Партнеры + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/modules/main/components/recent-news.tsx b/src/modules/main/components/recent-news.tsx new file mode 100644 index 0000000..4197e0f --- /dev/null +++ b/src/modules/main/components/recent-news.tsx @@ -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 ( + + + + + Последние новости + + {news.isLoading && ( + + Загружаем последние новости... + + )} + {news.error && ( + + {news.error} + + )} + {hasNews && ( + + {visibleNews.map((item) => ( + + + + + {item.summary} + Читать полностью + + + + ))} + + )} + + Лента новостей + + + + + ); +}; + +export const RecentNews = observer(RecentNewsComponent); diff --git a/src/modules/main/components/services.tsx b/src/modules/main/components/services.tsx new file mode 100644 index 0000000..997aa2f --- /dev/null +++ b/src/modules/main/components/services.tsx @@ -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 ( + + + + + Наши услуги + + {services.isLoading && ( + + Загружаем услуги... + + )} + {services.error && ( + + {services.error} + + )} + {hasServices && ( + + {visibleServices.map((service) => ( + + + + + + {formatPrice(service.priceFrom)} + + + {service.description} + + Подробнее + + + + ))} + + )} + {!services.isLoading && !services.error && !hasServices && ( + + Услуги скоро появятся. + + )} + + Все услуги + + + + + ); +}; + +export const Services = observer(ServicesComponent); diff --git a/src/modules/main/pages/index.tsx b/src/modules/main/pages/index.tsx new file mode 100644 index 0000000..c1c49e5 --- /dev/null +++ b/src/modules/main/pages/index.tsx @@ -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 ( + + + + + + + + + + + ); +}; diff --git a/src/modules/news/pages/index.ts b/src/modules/news/pages/index.ts new file mode 100644 index 0000000..7bdb4bf --- /dev/null +++ b/src/modules/news/pages/index.ts @@ -0,0 +1,2 @@ +export { NewsFeedPage } from './news-feed.tsx'; +export { NewsDetailsPage } from './news-details.tsx'; diff --git a/src/modules/news/pages/news-details.tsx b/src/modules/news/pages/news-details.tsx new file mode 100644 index 0000000..25e2bc3 --- /dev/null +++ b/src/modules/news/pages/news-details.tsx @@ -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 ( + + + + + + + + Главная + + + Новости + + {resolvedNews?.title ?? 'Загрузка...'} + + {isLoading && ( + + + + + + )} + {showError && ( + + {news.error} + + )} + {!isLoading && !showError && resolvedNews && ( + + + {resolvedNews.title} + + + {formatDate(resolvedNews.publishedAt)} + + {resolvedNews.imageUrl ? ( + + ) : null} + {resolvedNews.summary} + {resolvedNews.content && ( + + {resolvedNews.content} + + )} + + )} + {!isLoading && !showError && !resolvedNews && ( + + Новость не найдена или была удалена. + + )} + + + + + + ); +}; + +export const NewsDetailsPage = observer(NewsDetailsComponent); diff --git a/src/modules/news/pages/news-feed.tsx b/src/modules/news/pages/news-feed.tsx new file mode 100644 index 0000000..de4327d --- /dev/null +++ b/src/modules/news/pages/news-feed.tsx @@ -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 ( + + + + + + + Новости + + {news.isLoading && ( + + Загружаем новости... + + )} + {news.error && ( + + {news.error} + + )} + {!news.isLoading && !news.error && !hasItems && ( + + Новости пока не опубликованы. + + )} + {hasItems && ( + + {items.map((item) => ( + + + {item.imageUrl && ( + + )} + + + + {item.summary} + + Читать полностью + + + + ))} + + )} + {hasItems && totalPages > 1 && ( + setPage(value)} + count={totalPages} + color="primary" + sx={{ alignSelf: 'center' }} + showFirstButton + showLastButton + /> + )} + + + + + + ); +}; + +export const NewsFeedPage = observer(NewsFeedComponent); diff --git a/src/modules/news/utils/formatDate.ts b/src/modules/news/utils/formatDate.ts new file mode 100644 index 0000000..28a1b09 --- /dev/null +++ b/src/modules/news/utils/formatDate.ts @@ -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); +}; diff --git a/src/modules/services/pages/index.ts b/src/modules/services/pages/index.ts new file mode 100644 index 0000000..385733d --- /dev/null +++ b/src/modules/services/pages/index.ts @@ -0,0 +1,2 @@ +export { ServicesListPage } from './services-list.tsx'; +export { ServiceDetailsPage } from './service-details.tsx'; diff --git a/src/modules/services/pages/service-details.tsx b/src/modules/services/pages/service-details.tsx new file mode 100644 index 0000000..9f45601 --- /dev/null +++ b/src/modules/services/pages/service-details.tsx @@ -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 ( + + + + + + + + Главная + + + Услуги + + {resolved?.title ?? 'Загрузка...'} + + {isLoading && ( + + + + + + )} + {showError && ( + + {services.error} + + )} + {!isLoading && !showError && resolved && ( + + + {resolved.title} + + {resolved.imageUrl ? ( + + ) : null} + + {resolved.category?.name ?? 'Без категории'} + Стоимость: {formatPrice(resolved.priceFrom)} + + Обновлено: {formatDate(resolved.updatedAt)} | Создано: {formatDate(resolved.createdAt)} + + + {resolved.description} + + Вернуться к списку услуг + + + )} + {!isLoading && !showError && !resolved && ( + + Услуга не найдена или была удалена. + + )} + + + + + + ); +}; + +export const ServiceDetailsPage = observer(ServiceDetailsComponent); diff --git a/src/modules/services/pages/services-list.tsx b/src/modules/services/pages/services-list.tsx new file mode 100644 index 0000000..d6e64ef --- /dev/null +++ b/src/modules/services/pages/services-list.tsx @@ -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 ( + + + + + + + Услуги + + {services.isLoading && ( + + Загружаем услуги... + + )} + {services.error && ( + + {services.error} + + )} + {!services.isLoading && !services.error && !hasItems && ( + + Услуги пока не добавлены. + + )} + {hasItems && ( + + {items.map((service) => ( + + + {service.imageUrl ? ( + + ) : null} + + + + {service.description} + + {formatPrice(service.priceFrom)} + + Подробнее + + + + + ))} + + )} + {hasItems && totalPages > 1 && ( + setPage(value)} + count={totalPages} + color="primary" + sx={{ alignSelf: 'center' }} + showFirstButton + showLastButton + /> + )} + + + + + + ); +}; + +export const ServicesListPage = observer(ServicesListComponent); diff --git a/src/modules/services/utils/formatPrice.ts b/src/modules/services/utils/formatPrice.ts new file mode 100644 index 0000000..8e863cb --- /dev/null +++ b/src/modules/services/utils/formatPrice.ts @@ -0,0 +1,7 @@ +export const formatPrice = (value: number | null | undefined) => { + if (value === null || value === undefined) { + return 'Цена по запросу'; + } + + return `от ${value.toLocaleString('ru-RU')} ₽`; +}; diff --git a/src/providers/store.tsx b/src/providers/store.tsx new file mode 100644 index 0000000..55723f1 --- /dev/null +++ b/src/providers/store.tsx @@ -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(rootStore); + +// eslint-disable-next-line react-refresh/only-export-components +export const useRootStore = () => useContext(StoreContext); + +export const StoreProvider: React.FC = ({ children }) => { + return {children}; +}; diff --git a/src/shared/components/footer.tsx b/src/shared/components/footer.tsx new file mode 100644 index 0000000..c19af48 --- /dev/null +++ b/src/shared/components/footer.tsx @@ -0,0 +1,17 @@ +import { Container, Box, Typography, Link } from '@mui/material'; + +export const Footer = () => { + return ( + + + + © 2025 БанкИнфо. Все права защищены. + Информационный портал о банковских услугах. + г. Москва, ул. Примерная, д. 5 | info@bankinfo.ru |{' '} + +7 (495) 000-00-00 + Информация, размещённая на сайте, не является публичной офертой. + + + + ); +}; diff --git a/src/shared/components/header.tsx b/src/shared/components/header.tsx new file mode 100644 index 0000000..9c9dab2 --- /dev/null +++ b/src/shared/components/header.tsx @@ -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 ( + + ); +}; + +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 ( + + + + + + + + + Главная + + + + + Услуги + + + + + Новости + + + + + О нас + + + + + Контакты + + + + Оставить заявку + + + + + + + ); +}; diff --git a/src/shared/hooks/usePageTitle.ts b/src/shared/hooks/usePageTitle.ts new file mode 100644 index 0000000..5cbca99 --- /dev/null +++ b/src/shared/hooks/usePageTitle.ts @@ -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]); +}; diff --git a/src/shared/hooks/useStore.ts b/src/shared/hooks/useStore.ts new file mode 100644 index 0000000..f81472c --- /dev/null +++ b/src/shared/hooks/useStore.ts @@ -0,0 +1 @@ +export { useRootStore as useStore } from '../../providers/store.tsx' \ No newline at end of file diff --git a/src/shared/theme/ColorModeIconDropdown.tsx b/src/shared/theme/ColorModeIconDropdown.tsx new file mode 100644 index 0000000..0a24099 --- /dev/null +++ b/src/shared/theme/ColorModeIconDropdown.tsx @@ -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 = (props) => { + const { mode, systemMode, setMode } = useColorScheme(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => { + setMode(targetMode); + handleClose(); + }; + if (!mode) { + return ( + ({ + 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: , + dark: , + }[resolvedMode]; + return ( + + + {icon} + + + + System + + + Light + + + Dark + + + + ); +}; diff --git a/src/shared/theme/ColorModeSelect.tsx b/src/shared/theme/ColorModeSelect.tsx new file mode 100644 index 0000000..0e46e51 --- /dev/null +++ b/src/shared/theme/ColorModeSelect.tsx @@ -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 = (props) => { + const { mode, setMode } = useColorScheme(); + if (!mode) { + return null; + } + return ( + setMode(event.target.value as 'system' | 'light' | 'dark')} + SelectDisplayProps={{ + // @ts-expect-error MUI Docs used + 'data-screenshot': 'toggle-mode', + }} + {...props} + > + System + Light + Dark + + ); +}; diff --git a/src/shared/theme/customizations/dataDisplay.tsx b/src/shared/theme/customizations/dataDisplay.tsx new file mode 100644 index 0000000..24d598c --- /dev/null +++ b/src/shared/theme/customizations/dataDisplay.tsx @@ -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 = { + 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', + }, + }, + ], + }, + }, + }, +}; diff --git a/src/shared/theme/customizations/feedback.tsx b/src/shared/theme/customizations/feedback.tsx new file mode 100644 index 0000000..d124423 --- /dev/null +++ b/src/shared/theme/customizations/feedback.tsx @@ -0,0 +1,45 @@ +import { type Theme, alpha, type Components } from '@mui/material/styles'; +import { gray, orange } from '../themePrimitives.ts'; + +export const feedbackCustomizations: Components = { + 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], + }), + }), + }, + }, +}; diff --git a/src/shared/theme/customizations/inputs.tsx b/src/shared/theme/customizations/inputs.tsx new file mode 100644 index 0000000..923d97c --- /dev/null +++ b/src/shared/theme/customizations/inputs.tsx @@ -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 = { + 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: , + checkedIcon: , + indeterminateIcon: , + }, + 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, + }), + }, + }, +}; diff --git a/src/shared/theme/customizations/navigation.tsx b/src/shared/theme/customizations/navigation.tsx new file mode 100644 index 0000000..3ff06c8 --- /dev/null +++ b/src/shared/theme/customizations/navigation.tsx @@ -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 = { + 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((props, 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 }), + }, + }), + }, + }, +}; diff --git a/src/shared/theme/customizations/surfaces.ts b/src/shared/theme/customizations/surfaces.ts new file mode 100644 index 0000000..e7dc424 --- /dev/null +++ b/src/shared/theme/customizations/surfaces.ts @@ -0,0 +1,112 @@ +import { alpha, type Theme, type Components } from '@mui/material/styles'; +import { gray } from '../themePrimitives.ts'; + +export const surfacesCustomizations: Components = { + 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, + }, + }, + }, +}; diff --git a/src/shared/theme/theme.tsx b/src/shared/theme/theme.tsx new file mode 100644 index 0000000..6c51733 --- /dev/null +++ b/src/shared/theme/theme.tsx @@ -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 = (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 {children}; + } + return ( + + {children} + + ); +}; diff --git a/src/shared/theme/themePrimitives.ts b/src/shared/theme/themePrimitives.ts new file mode 100644 index 0000000..ea0f01e --- /dev/null +++ b/src/shared/theme/themePrimitives.ts @@ -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; diff --git a/src/stores/admin/index.ts b/src/stores/admin/index.ts new file mode 100644 index 0000000..a58663b --- /dev/null +++ b/src/stores/admin/index.ts @@ -0,0 +1,2 @@ +export * from './serviceCategory.model.ts'; +export * from './serviceCategoryCollection.model.ts'; diff --git a/src/stores/admin/serviceCategory.model.ts b/src/stores/admin/serviceCategory.model.ts new file mode 100644 index 0000000..546fd70 --- /dev/null +++ b/src/stores/admin/serviceCategory.model.ts @@ -0,0 +1,37 @@ +import { makeAutoObservable } from 'mobx'; +import type { AdminServiceCategory } from '../../api/index.ts'; + +export class ServiceCategoryModel { + readonly id: string; + name = ''; + slug = ''; + + constructor(dto: AdminServiceCategory) { + this.id = String(dto.id); + this.hydrate(dto); + + makeAutoObservable(this, { id: false }, { autoBind: true }); + } + + update(dto: Partial) { + this.hydrate(dto); + } + + toJSON(): AdminServiceCategory { + return { + id: this.id, + name: this.name, + slug: this.slug, + }; + } + + private hydrate(dto: Partial) { + if (dto.name !== undefined) { + this.name = dto.name; + } + + if (dto.slug !== undefined) { + this.slug = dto.slug; + } + } +} diff --git a/src/stores/admin/serviceCategoryCollection.model.ts b/src/stores/admin/serviceCategoryCollection.model.ts new file mode 100644 index 0000000..3ccad0d --- /dev/null +++ b/src/stores/admin/serviceCategoryCollection.model.ts @@ -0,0 +1,66 @@ +import { makeAutoObservable, runInAction } from 'mobx'; + +import { adminApi, type AdminServiceCategory } from '../../api'; +import { ServiceCategoryModel } from './serviceCategory.model.ts'; + +export class ServiceCategoryCollectionModel { + readonly items = new Map(); + private readonly client = adminApi; + isLoading = false; + error: string | null = null; + + constructor(client = adminApi) { + this.client = client; + // @ts-expect-error Skip + makeAutoObservable(this, { client: false }, { autoBind: true }); + } + + get list() { + return Array.from(this.items.values()); + } + + get isEmpty() { + return this.items.size === 0; + } + + getById(id: string | number) { + return this.items.get(String(id)); + } + + async fetch() { + this.isLoading = true; + this.error = null; + + try { + const payload = await this.client.listServiceCategories(); + runInAction(() => { + this.hydrate(payload); + this.isLoading = false; + }); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load service categories'; + this.isLoading = false; + }); + throw error; + } + } + + private hydrate(payload: AdminServiceCategory[]) { + payload.forEach((item) => { + this.upsert(item); + }); + } + + private upsert(item: AdminServiceCategory) { + const key = String(item.id); + const existing = this.items.get(key); + + if (existing) { + existing.update(item); + return; + } + + this.items.set(key, new ServiceCategoryModel(item)); + } +} diff --git a/src/stores/news/index.ts b/src/stores/news/index.ts new file mode 100644 index 0000000..3152fc7 --- /dev/null +++ b/src/stores/news/index.ts @@ -0,0 +1,2 @@ +export * from './news.model.ts'; +export * from './newsCollection.model.ts'; diff --git a/src/stores/news/news.model.ts b/src/stores/news/news.model.ts new file mode 100644 index 0000000..fc78b69 --- /dev/null +++ b/src/stores/news/news.model.ts @@ -0,0 +1,71 @@ +import { makeAutoObservable } from 'mobx'; +import type { NewsItem } from '../../api'; + +export class NewsModel { + readonly id: string; + title = ''; + slug = ''; + summary = ''; + content = ''; + imageUrl: string | null = null; + publishedAt = ''; + status: 'draft' | 'published' | 'archived' = 'draft'; + + constructor(dto: NewsItem) { + this.id = String(dto.id); + this.hydrate(dto); + + makeAutoObservable(this, { id: false }, { autoBind: true }); + } + + get href() { + return `/news/${this.slug}`; + } + + update(dto: Partial) { + this.hydrate(dto); + } + + toJSON(): NewsItem { + return { + id: this.id, + title: this.title, + slug: this.slug, + summary: this.summary, + content: this.content, + imageUrl: this.imageUrl ?? undefined, + publishedAt: this.publishedAt, + status: this.status, + }; + } + + private hydrate(dto: Partial) { + if (dto.title !== undefined) { + this.title = dto.title; + } + + if (dto.slug !== undefined) { + this.slug = dto.slug; + } + + if (dto.summary !== undefined) { + this.summary = dto.summary; + } + + if (dto.content !== undefined) { + this.content = dto.content; + } + + if (dto.imageUrl !== undefined) { + this.imageUrl = dto.imageUrl ?? null; + } + + if (dto.publishedAt != undefined) { + this.publishedAt = dto.publishedAt; + } + + if (dto.status !== undefined) { + this.status = dto.status as typeof this.status; + } + } +} diff --git a/src/stores/news/newsCollection.model.ts b/src/stores/news/newsCollection.model.ts new file mode 100644 index 0000000..339bc97 --- /dev/null +++ b/src/stores/news/newsCollection.model.ts @@ -0,0 +1,127 @@ +import { makeAutoObservable, runInAction } from 'mobx'; +import { newsApi, adminApi, type NewsItem } from '../../api/index.ts'; +import type { ListNewsParams } from '../../api/news/index.ts'; +import { NewsModel } from './news.model.ts'; + +export class NewsCollectionModel { + readonly items = new Map(); + private readonly client = newsApi; + private readonly adminClient = adminApi; + isLoading = false; + error: string | null = null; + total = 0; + limit = 0; + offset = 0; + + constructor(client = newsApi, adminClient = adminApi) { + this.client = client; + this.adminClient = adminClient; + // @ts-expect-error (client is a known property) + makeAutoObservable(this, { client: false, adminClient: false }, { autoBind: true }); + } + + get list() { + return Array.from(this.items.values()); + } + + get isEmpty() { + return this.items.size === 0; + } + + getById(id: string | number) { + return this.items.get(String(id)); + } + + getBySlug(slug: string) { + return this.list.find((news) => news.slug === slug); + } + + async fetch(params?: ListNewsParams) { + this.isLoading = true; + this.error = null; + + try { + const payload = await this.client.list(params); + runInAction(() => { + this.total = payload.total; + this.limit = payload.limit; + this.offset = payload.offset; + if (params?.page || params?.limit) { + this.items.clear(); + } + this.hydrate(payload.items); + this.isLoading = false; + }); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load news'; + this.isLoading = false; + }); + throw error; + } + } + + async fetchBySlug(slug: string) { + this.isLoading = true; + this.error = null; + + try { + const payload = await this.client.get(slug); + runInAction(() => { + this.upsert(payload); + this.isLoading = false; + }); + return this.getBySlug(slug); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load news'; + this.isLoading = false; + }); + throw error; + } + } + + async fetchAdmin(params?: ListNewsParams, options?: { replace?: boolean }) { + this.isLoading = true; + this.error = null; + const shouldReplace = options?.replace ?? false; + + try { + const payload = await this.adminClient.listNews(params); + runInAction(() => { + if (shouldReplace) { + this.items.clear(); + } + this.total = payload.total; + this.limit = payload.limit; + this.offset = payload.offset; + this.hydrate(payload.items); + this.isLoading = false; + }); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load news'; + this.isLoading = false; + }); + throw error; + } + } + + private hydrate(payload: NewsItem[]) { + payload.forEach((item) => { + this.upsert(item); + }); + } + + private upsert(item: NewsItem) { + const key = String(item.id); + const existing = this.items.get(key); + + if (existing) { + existing.update(item); + return; + } + + this.items.set(key, new NewsModel(item)); + } +} diff --git a/src/stores/root.tsx b/src/stores/root.tsx new file mode 100644 index 0000000..7e3e6dd --- /dev/null +++ b/src/stores/root.tsx @@ -0,0 +1,13 @@ +import { NewsCollectionModel } from './news/index.ts'; +import { ServicesCollectionModel } from './services/index.ts'; +import { ServiceCategoryCollectionModel } from './admin/index.ts'; + +class RootStore { + news = new NewsCollectionModel(); + services = new ServicesCollectionModel(); + adminServiceCategories = new ServiceCategoryCollectionModel(); +} + +export type RootStoreType = RootStore; + +export const rootStore = new RootStore(); diff --git a/src/stores/services/index.ts b/src/stores/services/index.ts new file mode 100644 index 0000000..8251479 --- /dev/null +++ b/src/stores/services/index.ts @@ -0,0 +1,2 @@ +export * from './service.model.ts'; +export * from './servicesCollection.model.ts'; diff --git a/src/stores/services/service.model.ts b/src/stores/services/service.model.ts new file mode 100644 index 0000000..0f59fff --- /dev/null +++ b/src/stores/services/service.model.ts @@ -0,0 +1,92 @@ +import { makeAutoObservable } from 'mobx'; +import { type ServiceCategory, type ServiceItem } from '../../api'; + +const parsePrice = (value: ServiceItem['priceFrom']) => { + if (value === null || value === undefined) { + return null; + } + + const numericValue = typeof value === 'string' ? Number(value) : value; + return Number.isFinite(numericValue) ? Number(numericValue) : null; +}; + +export class ServiceModel { + readonly id: string; + title = ''; + slug = ''; + description = ''; + priceFrom: number | null = null; + imageUrl: string | null = null; + status = ''; + category: ServiceCategory | null = null; + createdAt = ''; + updatedAt = ''; + + constructor(dto: ServiceItem) { + this.id = String(dto.id); + this.hydrate(dto); + + makeAutoObservable(this, { id: false }, { autoBind: true }); + } + + get href() { + return this.slug ? `/services/${this.slug}` : `/services/${this.id}`; + } + + update(dto: Partial) { + this.hydrate(dto); + } + + toJSON(): ServiceItem { + return { + id: this.id, + title: this.title, + slug: this.slug, + description: this.description, + priceFrom: this.priceFrom ?? undefined, + imageUrl: this.imageUrl ?? undefined, + status: this.status, + category: this.category ?? undefined, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } + + private hydrate(dto: Partial) { + if (dto.title !== undefined) { + this.title = dto.title; + } + + if (dto.slug !== undefined) { + this.slug = dto.slug ?? ''; + } + + if (dto.description !== undefined) { + this.description = dto.description ?? ''; + } + + if (dto.priceFrom !== undefined) { + this.priceFrom = parsePrice(dto.priceFrom); + } + + if (dto.imageUrl !== undefined) { + this.imageUrl = dto.imageUrl ?? null; + } + + if (dto.status !== undefined) { + this.status = dto.status; + } + + if (dto.category !== undefined) { + this.category = dto.category ?? null; + } + + if (dto.createdAt !== undefined) { + this.createdAt = dto.createdAt ?? ''; + } + + if (dto.updatedAt !== undefined) { + this.updatedAt = dto.updatedAt ?? ''; + } + } +} diff --git a/src/stores/services/servicesCollection.model.ts b/src/stores/services/servicesCollection.model.ts new file mode 100644 index 0000000..670abbb --- /dev/null +++ b/src/stores/services/servicesCollection.model.ts @@ -0,0 +1,149 @@ +import { makeAutoObservable, runInAction } from 'mobx'; + +import { servicesApi, adminApi, type ServiceItem } from '../../api'; +import type { ListServicesParams } from '../../api/services'; +import { ServiceModel } from './service.model.ts'; + +export class ServicesCollectionModel { + readonly items = new Map(); + private readonly client = servicesApi; + private readonly adminClient = adminApi; + isLoading = false; + error: string | null = null; + total = 0; + limit = 0; + offset = 0; + + constructor(client = servicesApi, adminClient = adminApi) { + this.client = client; + this.adminClient = adminClient; + // @ts-expect-error (Client is expected here) + makeAutoObservable(this, { client: false, adminClient: false }, { autoBind: true }); + } + + get list() { + return Array.from(this.items.values()); + } + + get isEmpty() { + return this.items.size === 0; + } + + getById(id: string | number) { + return this.items.get(String(id)); + } + + getBySlug(slug: string) { + return this.list.find((service) => service.slug === slug); + } + + async fetch(params?: ListServicesParams, options?: { replace?: boolean }) { + this.isLoading = true; + this.error = null; + const shouldReplace = options?.replace ?? false; + + try { + const payload = await this.client.list(params); + runInAction(() => { + if (shouldReplace) { + this.items.clear(); + } + this.total = payload.total; + this.limit = payload.limit; + this.offset = payload.offset; + this.hydrate(payload.items); + this.isLoading = false; + }); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load services'; + this.isLoading = false; + }); + throw error; + } + } + + async fetchBySlug(slugOrId: string) { + this.isLoading = true; + this.error = null; + + try { + const payload = await this.client.get(slugOrId); + runInAction(() => { + this.upsert(payload); + this.isLoading = false; + }); + return this.getBySlug(payload.slug) ?? this.getById(payload.id); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load services'; + this.isLoading = false; + }); + throw error; + } + } + + async fetchAdmin(params?: ListServicesParams, options?: { replace?: boolean }) { + this.isLoading = true; + this.error = null; + const shouldReplace = options?.replace ?? false; + + try { + const payload = await this.adminClient.listAdminServices(params); + runInAction(() => { + if (shouldReplace) { + this.items.clear(); + } + this.total = payload.total; + this.limit = payload.limit; + this.offset = payload.offset; + this.hydrate(payload.items); + this.isLoading = false; + }); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load services'; + this.isLoading = false; + }); + throw error; + } + } + + async fetchBySlugAdmin(slugOrId: string) { + this.isLoading = true; + this.error = null; + + try { + const payload = await this.adminClient.getAdminService(slugOrId); + runInAction(() => { + this.upsert(payload); + this.isLoading = false; + }); + return this.getBySlug(payload.slug) ?? this.getById(payload.id); + } catch (error) { + runInAction(() => { + this.error = error instanceof Error ? error.message : 'Failed to load services'; + this.isLoading = false; + }); + throw error; + } + } + + private hydrate(payload: ServiceItem[]) { + payload.forEach((item) => { + this.upsert(item); + }); + } + + private upsert(item: ServiceItem) { + const key = String(item.id); + const existing = this.items.get(key); + + if (existing) { + existing.update(item); + return; + } + + this.items.set(key, new ServiceModel(item)); + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..934aa50 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,7 +22,8 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "allowSyntheticDefaultImports": true }, "include": ["src"] }
- Edit src/App.tsx and save to test HMR -
src/App.tsx
- Click on the Vite and React logos to learn more -