diff --git a/package.json b/package.json
index 8bf1b07..066b85a 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@seznam/compose-react-refs": "^1.0.6",
+ "@tanstack/react-query": "^4.33.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
@@ -10,11 +12,22 @@
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
+ "axios": "^1.4.0",
+ "clsx": "^2.0.0",
+ "date-fns": "^2.30.0",
+ "focus-visible": "^5.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-helmet": "^6.1.0",
+ "react-hook-form": "^7.45.4",
+ "react-router-dom": "^6.15.0",
"react-scripts": "5.0.1",
+ "sanitize.css": "^13.0.0",
+ "sass": "^1.66.1",
+ "ts-key-enum": "^2.0.12",
"typescript": "^4.4.2",
- "web-vitals": "^2.1.0"
+ "web-vitals": "^2.1.0",
+ "zustand": "^4.4.1"
},
"scripts": {
"start": "react-scripts start",
@@ -39,5 +52,9 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "devDependencies": {
+ "@types/date-fns": "^2.6.0",
+ "@types/react-helmet": "^6.1.6"
}
}
diff --git a/src/.prettierrc.js b/src/.prettierrc.js
new file mode 100644
index 0000000..92d8a9a
--- /dev/null
+++ b/src/.prettierrc.js
@@ -0,0 +1,12 @@
+module.exports = {
+ arrowParens: 'always',
+ bracketSameLine: true,
+ bracketSpacing: true,
+ endOfLine: 'auto',
+ printWidth: 120,
+ semi: true,
+ singleQuote: true,
+ tabWidth: 2,
+ trailingComma: 'none',
+ useTabs: false
+};
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index 74b5e05..0000000
--- a/src/App.css
+++ /dev/null
@@ -1,38 +0,0 @@
-.App {
- text-align: center;
-}
-
-.App-logo {
- height: 40vmin;
- pointer-events: none;
-}
-
-@media (prefers-reduced-motion: no-preference) {
- .App-logo {
- animation: App-logo-spin infinite 20s linear;
- }
-}
-
-.App-header {
- background-color: #282c34;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
-}
-
-.App-link {
- color: #61dafb;
-}
-
-@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
diff --git a/src/App.test.tsx b/src/App.test.tsx
deleted file mode 100644
index 2a68616..0000000
--- a/src/App.test.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import App from './App';
-
-test('renders learn react link', () => {
- render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
-});
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index a53698a..0000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import logo from './logo.svg';
-import './App.css';
-
-function App() {
- return (
-
- );
-}
-
-export default App;
diff --git a/src/api/deck/createAnswer.ts b/src/api/deck/createAnswer.ts
new file mode 100644
index 0000000..1eec40c
--- /dev/null
+++ b/src/api/deck/createAnswer.ts
@@ -0,0 +1,36 @@
+import {Answer, PitchDeck} from './types';
+import {DECKS_API_URL, QUESTION_API_URL, QUESTION_PARAM_DECK_ID, QUESTION_PARAM_QUESTION_ID} from './urlKeys';
+import {MutationConfig, queryClient} from '../../lib/react-query';
+import {useMutation} from '@tanstack/react-query';
+import {QUERY_KEY_ANSWER} from './queryKeys';
+import { axios } from '../../lib/axios';
+
+export type CreateAnswerDTO = {
+ deckId: number;
+ questionId: number;
+ answer: any;
+};
+
+export type CreateAnswerResponse = Answer;
+
+export const createAnswer = (data: CreateAnswerDTO): Promise => {
+ const path = QUESTION_API_URL
+ .replace(`:${QUESTION_PARAM_DECK_ID}`, String(data.deckId))
+ .replace(`:${QUESTION_PARAM_QUESTION_ID}`, String(data.questionId)) + '/'
+
+ return axios.post(path, { answer: data.answer });
+};
+
+type UseCreateAnswerOptions = {
+ config?: MutationConfig;
+};
+
+export const useCreateAnswer = ({ config }: UseCreateAnswerOptions = {}) => {
+ return useMutation({
+ onMutate: async () => {
+ await queryClient.cancelQueries([QUERY_KEY_ANSWER]);
+ },
+ ...config,
+ mutationFn: createAnswer
+ });
+};
diff --git a/src/api/deck/createDeck.ts b/src/api/deck/createDeck.ts
new file mode 100644
index 0000000..e3b7b6c
--- /dev/null
+++ b/src/api/deck/createDeck.ts
@@ -0,0 +1,29 @@
+import {PitchDeck} from './types';
+import {DECKS_API_URL} from './urlKeys';
+import {MutationConfig, queryClient} from '../../lib/react-query';
+import {useMutation} from '@tanstack/react-query';
+import {QUERY_KEY_DECKS} from './queryKeys';
+import { axios } from '../../lib/axios';
+
+export type CreateDeckDTO = Pick;
+
+export type CreateDeckResponse = PitchDeck;
+
+export const createDeck = (data: CreateDeckDTO): Promise => {
+ return axios.post(DECKS_API_URL, data);
+};
+
+type UseCreateDeckOptions = {
+ config?: MutationConfig;
+};
+
+export const useCreateDeck = ({ config }: UseCreateDeckOptions = {}) => {
+
+ return useMutation({
+ onMutate: async () => {
+ await queryClient.cancelQueries([QUERY_KEY_DECKS]);
+ },
+ ...config,
+ mutationFn: createDeck
+ });
+};
diff --git a/src/api/deck/getFirstQuestion.ts b/src/api/deck/getFirstQuestion.ts
new file mode 100644
index 0000000..15aa23e
--- /dev/null
+++ b/src/api/deck/getFirstQuestion.ts
@@ -0,0 +1,31 @@
+import {Question} from './types';
+import {axios} from '../../lib/axios';
+import {FIRST_QUESTION_API_URL, FIRST_QUESTION_PARAM} from './urlKeys';
+import {ExtractFnReturnType, QueryConfig} from '../../lib/react-query';
+import {useQuery} from '@tanstack/react-query';
+import {QUERY_KEY_FIRST_QUESTION} from './queryKeys';
+
+export type GetFirstQuestionResponse = Question;
+
+export const getFirstQuestion = ({ deckId }: { deckId: number; }): Promise => {
+ return axios.get(FIRST_QUESTION_API_URL.replace(`:${FIRST_QUESTION_PARAM}`, String(deckId)));
+};
+
+type QueryFnType = typeof getFirstQuestion;
+
+type UseFirstQuestionOptions = {
+ deckId: number;
+ config?: QueryConfig;
+};
+
+export const useFirstQuestion = ({ deckId, config }: UseFirstQuestionOptions) => {
+ return useQuery>({
+ ...config,
+ queryKey: [QUERY_KEY_FIRST_QUESTION, deckId],
+ queryFn: async () => {
+ const process = await getFirstQuestion({ deckId });
+
+ return process;
+ },
+ });
+};
diff --git a/src/api/deck/getQuestion.ts b/src/api/deck/getQuestion.ts
new file mode 100644
index 0000000..e0f546a
--- /dev/null
+++ b/src/api/deck/getQuestion.ts
@@ -0,0 +1,38 @@
+import {Question} from './types';
+import {axios} from '../../lib/axios';
+import {
+ QUESTION_API_URL,
+ QUESTION_PARAM_DECK_ID,
+ QUESTION_PARAM_QUESTION_ID,
+} from './urlKeys';
+import {ExtractFnReturnType, QueryConfig} from '../../lib/react-query';
+import {useQuery} from '@tanstack/react-query';
+import {QUERY_KEY_QUESTION} from './queryKeys';
+
+export type GetQuestionResponse = Question;
+
+export const getQuestion = ({ deckId, questionId }: { deckId: number; questionId: number; }): Promise => {
+ return axios.get(
+ QUESTION_API_URL
+ .replace(`:${QUESTION_PARAM_DECK_ID}`, String(deckId))
+ .replace(`:${QUESTION_PARAM_QUESTION_ID}`, String(questionId))
+ );
+};
+
+type QueryFnType = typeof getQuestion;
+
+type UseQuestionOptions = {
+ deckId: number;
+ questionId: number;
+ config?: QueryConfig;
+};
+
+export const useQuestion = ({ deckId, questionId, config }: UseQuestionOptions) => {
+ return useQuery>({
+ ...config,
+ queryKey: [QUERY_KEY_QUESTION, deckId, questionId],
+ queryFn: async () => {
+ return await getQuestion({ deckId, questionId });
+ },
+ });
+};
diff --git a/src/api/deck/index.ts b/src/api/deck/index.ts
new file mode 100644
index 0000000..cdb7fe3
--- /dev/null
+++ b/src/api/deck/index.ts
@@ -0,0 +1,6 @@
+export * from './types';
+export * from './urlKeys';
+export * from './queryKeys';
+
+export * from './createDeck';
+export * from './getFirstQuestion';
\ No newline at end of file
diff --git a/src/api/deck/queryKeys.ts b/src/api/deck/queryKeys.ts
new file mode 100644
index 0000000..d414265
--- /dev/null
+++ b/src/api/deck/queryKeys.ts
@@ -0,0 +1,4 @@
+export const QUERY_KEY_DECKS = 'decks';
+export const QUERY_KEY_FIRST_QUESTION = 'firstQuestion';
+export const QUERY_KEY_QUESTION = 'question';
+export const QUERY_KEY_ANSWER = 'answer';
\ No newline at end of file
diff --git a/src/api/deck/types.ts b/src/api/deck/types.ts
new file mode 100644
index 0000000..98ddcd3
--- /dev/null
+++ b/src/api/deck/types.ts
@@ -0,0 +1,45 @@
+export enum EntityType {
+ text = 'text', //
+ number = 'number', //
+ range = 'range', //
+ multiple_range = 'multiple_range', //
+ select = 'select', //
+ link = 'link', // добавить валидацию
+ date = 'date', // добавить правильную установку хинта
+ photo = 'photo',
+ multiple_photo = 'multiple_photo',
+ photo_description = 'photo_description',
+ multiple_link_description = 'multiple_link_description',
+ multiple_photo_description = 'multiple_photo_description',
+ multiple_links = 'multiple_links',
+ multiple_date_description = 'multiple_date_description', //
+ text_array = 'text_array', // используется только в подсказке
+ cards = 'cards', // используется только в подсказке
+}
+
+export type PitchDeck = {
+ id: number;
+ name: string;
+ description?: string;
+ questions?: any[];
+};
+
+export type Hint = {
+ type: EntityType;
+ value: any;
+}
+
+export type Question = {
+ id: number;
+ text: string;
+ type: EntityType;
+ hint: Hint | false;
+ next_id: number;
+ params: { [key: string]: any } | null;
+};
+
+export type Answer = {
+ answer: any;
+ deck: number;
+ question: number;
+}
\ No newline at end of file
diff --git a/src/api/deck/urlKeys.ts b/src/api/deck/urlKeys.ts
new file mode 100644
index 0000000..53c8b2b
--- /dev/null
+++ b/src/api/deck/urlKeys.ts
@@ -0,0 +1,9 @@
+export const DECKS_API_URL = '/decks/';
+
+
+export const FIRST_QUESTION_PARAM = 'deckId';
+export const FIRST_QUESTION_API_URL = `/decks/question/:${FIRST_QUESTION_PARAM}`;
+
+export const QUESTION_PARAM_DECK_ID = 'deckId';
+export const QUESTION_PARAM_QUESTION_ID = 'questionId';
+export const QUESTION_API_URL = `/decks/question/:${FIRST_QUESTION_PARAM}/:${QUESTION_PARAM_QUESTION_ID}`;
\ No newline at end of file
diff --git a/src/app/App.tsx b/src/app/App.tsx
new file mode 100644
index 0000000..d5ddfc2
--- /dev/null
+++ b/src/app/App.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import {AppRoutes} from './routes';
+
+export function App() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
diff --git a/src/app/AppProvider.tsx b/src/app/AppProvider.tsx
new file mode 100644
index 0000000..3a1fa35
--- /dev/null
+++ b/src/app/AppProvider.tsx
@@ -0,0 +1,13 @@
+import {FC, PropsWithChildren} from 'react';
+import {BrowserRouter} from 'react-router-dom';
+import {ReactQueryProvider} from './providers';
+
+export const AppProvider: FC = ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/index.ts b/src/app/index.ts
new file mode 100644
index 0000000..77b9d24
--- /dev/null
+++ b/src/app/index.ts
@@ -0,0 +1,2 @@
+export * from './App';
+export * from './AppProvider';
\ No newline at end of file
diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts
new file mode 100644
index 0000000..0dd2cb5
--- /dev/null
+++ b/src/app/providers/index.ts
@@ -0,0 +1 @@
+export * from './react-query';
\ No newline at end of file
diff --git a/src/app/providers/react-query.tsx b/src/app/providers/react-query.tsx
new file mode 100644
index 0000000..bd4f159
--- /dev/null
+++ b/src/app/providers/react-query.tsx
@@ -0,0 +1,11 @@
+import {ReactFCC} from '../../utils/ReactFCC';
+import {QueryClientProvider} from '@tanstack/react-query';
+import {queryClient} from '../../lib/react-query';
+
+export const ReactQueryProvider: ReactFCC = ({ children }) => {
+ return (
+
+ {children}
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx
new file mode 100644
index 0000000..fc18f3c
--- /dev/null
+++ b/src/app/routes/AppRoutes.tsx
@@ -0,0 +1,13 @@
+import {Route, Routes} from 'react-router-dom';
+import {ChatPage} from '../../pages/chat';
+import {CHAT_PAGE_ROUTE} from './routes';
+
+export const AppRoutes = () => {
+
+ return (
+
+ } />
+
+ );
+};
+
diff --git a/src/app/routes/index.ts b/src/app/routes/index.ts
new file mode 100644
index 0000000..61f74e0
--- /dev/null
+++ b/src/app/routes/index.ts
@@ -0,0 +1,2 @@
+export * from './AppRoutes';
+export * from './routes';
\ No newline at end of file
diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts
new file mode 100644
index 0000000..972cf7b
--- /dev/null
+++ b/src/app/routes/routes.ts
@@ -0,0 +1 @@
+export const CHAT_PAGE_ROUTE = `/chat`;
\ No newline at end of file
diff --git a/src/app/styles/baseText.scss b/src/app/styles/baseText.scss
new file mode 100644
index 0000000..8f210f3
--- /dev/null
+++ b/src/app/styles/baseText.scss
@@ -0,0 +1,153 @@
+$font-family-raleway: 'Raleway';
+$font-family-ibm-plex-mono: 'IBM Plex Mono';
+
+$font-family-default: system-ui, /* macOS 10.11-10.12 */ -apple-system, /* Windows 6+ */ 'Segoe UI',
+ /* Android 4+ */ 'Roboto', /* Ubuntu 10.10+ */ 'Ubuntu', /* Gnome 3+ */ 'Cantarell', /* KDE Plasma 5+ */ 'Noto Sans',
+ /* fallback */ sans-serif, /* macOS emoji */ 'Apple Color Emoji', /* Windows emoji */ 'Segoe UI Emoji',
+ /* Windows emoji */ 'Segoe UI Symbol', /* Linux emoji */ 'Noto Color Emoji';
+
+// Моноширинный набор шрифтов.
+$font-family-mono: 'Menlo', 'Consolas', 'Roboto Mono', 'Ubuntu Monospace', 'Noto Mono', 'Oxygen Mono', 'Liberation Mono',
+ monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+
+$font-size-10: 10px;
+$font-size-12: 12px;
+$font-size-14: 14px;
+$font-size-16: 16px;
+$font-size-18: 18px;
+$font-size-24: 24px;
+$font-size-32: 32px;
+$font-size-56: 56px;
+
+$font-weight-400: 400;
+$font-weight-500: 500;
+$font-weight-600: 600;
+$font-weight-700: 700;
+
+$line-height-16: 16px;
+$line-height-20: 20px;
+$line-height-24: 24px;
+$line-height-32: 32px;
+$line-height-40: 40px;
+$line-height-67: 67px;
+
+@mixin text-header-h1 {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-56;
+ font-weight: $font-weight-700;
+ line-height: $line-height-67;
+}
+
+@mixin text-header-h2 {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-32;
+ font-weight: $font-weight-700;
+ line-height: $line-height-40;
+}
+
+@mixin text-header-h3 {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-24;
+ font-weight: $font-weight-700;
+ line-height: $line-height-32;
+}
+
+@mixin text-header-h4 {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-18;
+ font-weight: $font-weight-700;
+ line-height: $line-height-24;
+}
+
+@mixin text-body-l-medium {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-16;
+ font-weight: $font-weight-600;
+ line-height: $line-height-20;
+}
+
+@mixin text-body-l-regular {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-16;
+ font-weight: $font-weight-400;
+ line-height: $line-height-20;
+}
+
+@mixin text-body-m-medium {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-14;
+ font-weight: $font-weight-600;
+ line-height: $line-height-20;
+}
+
+@mixin text-body-m-regular {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-14;
+ font-weight: $font-weight-400;
+ line-height: $line-height-20;
+}
+
+@mixin text-body-s-medium {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-12;
+ font-weight: $font-weight-600;
+ line-height: $line-height-16;
+}
+
+@mixin text-body-s-regular {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-12;
+ font-weight: $font-weight-400;
+ line-height: $line-height-16;
+}
+
+@mixin text-caption-m-medium {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-14;
+ font-weight: $font-weight-500;
+ line-height: $line-height-20;
+}
+
+@mixin text-caption-m-regular {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-14;
+ font-weight: $font-weight-400;
+ line-height: $line-height-20;
+}
+
+@mixin text-caption-s-medium {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-10;
+ font-weight: $font-weight-500;
+ line-height: $line-height-16;
+}
+
+@mixin text-caption-s-regular {
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-10;
+ font-weight: $font-weight-400;
+ line-height: $line-height-16;
+}
+
+@mixin text-caption-all-caps {
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-12;
+ font-weight: $font-weight-600;
+ line-height: $line-height-20;
+}
+
+@mixin text-programming-code-medium {
+ font-family: $font-family-ibm-plex-mono, $font-family-mono;
+ font-size: $font-size-12;
+ font-weight: $font-weight-400;
+ line-height: $line-height-20;
+}
+
+@mixin text-programming-code-regular {
+ font-family: $font-family-ibm-plex-mono, $font-family-mono;
+ font-size: $font-size-12;
+ font-weight: $font-weight-400;
+ line-height: $line-height-16;
+}
diff --git a/src/app/styles/breakpoints.scss b/src/app/styles/breakpoints.scss
new file mode 100644
index 0000000..701932d
--- /dev/null
+++ b/src/app/styles/breakpoints.scss
@@ -0,0 +1,71 @@
+$breakpoint-mobile-small: 320px;
+$breakpoint-mobile-large: 375px;
+$breakpoint-tablet-small: 768px;
+$breakpoint-tablet-large: 1024px;
+$breakpoint-desktop-small: 1280px;
+$breakpoint-desktop-medium: 1440px;
+$breakpoint-desktop-large: 1920px;
+
+@mixin media-up($breakpoint) {
+ @if $breakpoint == mobile-small {
+ @media screen and (min-width: $breakpoint-mobile-small) {
+ @content;
+ }
+ } @else if $breakpoint == mobile-large {
+ @media screen and (min-width: $breakpoint-mobile-large) {
+ @content;
+ }
+ } @else if $breakpoint == tablet-small {
+ @media screen and (min-width: $breakpoint-tablet-small) {
+ @content;
+ }
+ } @else if $breakpoint == tablet-large {
+ @media screen and (min-width: $breakpoint-tablet-large) {
+ @content;
+ }
+ } @else if $breakpoint == desktop-small {
+ @media screen and (min-width: $breakpoint-desktop-small) {
+ @content;
+ }
+ } @else if $breakpoint == desktop-medium {
+ @media screen and (min-width: $breakpoint-desktop-medium) {
+ @content;
+ }
+ } @else if $breakpoint == desktop-large {
+ @media screen and (min-width: $breakpoint-desktop-large) {
+ @content;
+ }
+ }
+}
+
+@mixin media-down($breakpoint) {
+ @if $breakpoint == mobile-small {
+ @media screen and (max-width: $breakpoint-mobile-small - 1) {
+ @content;
+ }
+ } @else if $breakpoint == mobile-large {
+ @media screen and (max-width: $breakpoint-mobile-large - 1) {
+ @content;
+ }
+ } @else if $breakpoint == tablet-small {
+ @media screen and (max-width: $breakpoint-tablet-small - 1) {
+ @content;
+ }
+ } @else if $breakpoint == tablet-large {
+ @media screen and (max-width: $breakpoint-tablet-large - 1) {
+ @content;
+ }
+ } @else if $breakpoint == desktop-small {
+ @media screen and (max-width: $breakpoint-desktop-small - 1) {
+ @content;
+ }
+ } @else if $breakpoint == desktop-medium {
+ @media screen and (max-width: $breakpoint-desktop-medium - 1) {
+ @content;
+ }
+ } @else if $breakpoint == desktop-large {
+ @media screen and (max-width: $breakpoint-desktop-large - 1) {
+ @content;
+ }
+ }
+}
diff --git a/src/app/styles/colors.scss b/src/app/styles/colors.scss
new file mode 100644
index 0000000..6a6bc7c
--- /dev/null
+++ b/src/app/styles/colors.scss
@@ -0,0 +1,71 @@
+/* --- surface --- */
+$color-surface-primary: #141414;
+
+/* --- on-surface --- */
+$color-on-surface-dark-100: #1d1d1d;
+$color-on-surface-dark-200: #252525;
+$color-on-surface-dark-300: #2d2d2d;
+$color-on-surface-dark-400: #3e3e3e;
+$color-on-surface-light-400: #5e6166;
+$color-on-surface-light-300: #868a92;
+$color-on-surface-light-200: #c5c6ca;
+$color-on-surface-light-100: #fafafa;
+$color-on-surface-quaternary: #2b2b2b;
+
+/* --- text --- */
+$color-text-primary: #fafafa;
+$color-text-secondary: #c5c6ca;
+$color-text-tertiary: #868a92;
+$color-text-quaternary: #5e6166;
+$color-text-dark: #2b2b2b;
+$color-text-brand: #8e85e5;
+
+/* --- brand --- */
+$color-brand-primary: #695fcf;
+$color-brand-hover: #9288f8;
+$color-brand-disabled: #49428e;
+
+/* --- system --- */
+$color-system-link: #7f9ef3;
+$color-system-success: #10b981;
+$color-system-warning: #f59e0b;
+$color-system-error: #f43f5e;
+$color-system-link-weak: #142144;
+$color-system-success-weak: #042c1f;
+$color-system-warning-weak: #3d2907;
+$color-system-error-weak: #2d1015;
+$color-system-link-hover: #a8c1f8;
+
+/* --- accent --- */
+$color-accent-blue-100: #7f9ef3;
+$color-accent-blue-200: #2b4acb;
+$color-accent-blue-300: #203175;
+$color-accent-blue-400: #191e40;
+$color-accent-pink-100: #f37fb7;
+$color-accent-pink-200: #cb2b83;
+$color-accent-pink-300: #75204f;
+$color-accent-pink-400: #40162f;
+$color-accent-purple-100: #ab7ae0;
+$color-accent-purple-200: #642ab5;
+$color-accent-purple-300: #3e2069;
+$color-accent-purple-400: #21183b;
+$color-accent-green-100: #8fd460;
+$color-accent-green-200: #49aa19;
+$color-accent-green-300: #306317;
+$color-accent-green-400: #1d3712;
+$color-accent-cyan-100: #58d1c9;
+$color-accent-cyan-200: #13a8a8;
+$color-accent-cyan-300: #146262;
+$color-accent-cyan-400: #163b2e;
+$color-accent-orange-100: #f3b765;
+$color-accent-orange-200: #d87a16;
+$color-accent-orange-300: #7c4a15;
+$color-accent-orange-400: #442a11;
+$color-accent-yellow-100: #f3ea62;
+$color-accent-yellow-200: #d8bd14;
+$color-accent-yellow-300: #7c6e14;
+$color-accent-yellow-400: #443611;
+$color-accent-red-100: #f37370;
+$color-accent-red-200: #d32029;
+$color-accent-red-300: #791a1f;
+$color-accent-red-400: #421716;
\ No newline at end of file
diff --git a/src/app/styles/fonts.scss b/src/app/styles/fonts.scss
new file mode 100644
index 0000000..a394c49
--- /dev/null
+++ b/src/app/styles/fonts.scss
@@ -0,0 +1,48 @@
+@import './baseText';
+
+$fonts-path: '../../assets/fonts' !default;
+
+@font-face {
+ font-family: $font-family-raleway;
+ src: url('#{$fonts-path}/Raleway/Raleway-Regular.woff2') format('woff2'),
+ url('#{$fonts-path}/Raleway/Raleway-Regular.woff') format('woff');
+ font-weight: $font-weight-400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: $font-family-raleway;
+ src: url('#{$fonts-path}/Raleway/Raleway-Medium.woff2') format('woff2'),
+ url('#{$fonts-path}/Raleway/Raleway-Medium.woff') format('woff');
+ font-weight: $font-weight-500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: $font-family-raleway;
+ src: url('#{$fonts-path}/Raleway/Raleway-SemiBold.woff2') format('woff2'),
+ url('#{$fonts-path}/Raleway/Raleway-SemiBold.woff') format('woff');
+ font-weight: $font-weight-600;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: $font-family-raleway;
+ src: url('#{$fonts-path}/Raleway/Raleway-Bold.woff2') format('woff2'),
+ url('#{$fonts-path}/Raleway/Raleway-Bold.woff') format('woff');
+ font-weight: $font-weight-700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: $font-family-ibm-plex-mono;
+ src: url('#{$fonts-path}/IBM_Plex_Mono/IBMPlexMono-Regular.woff2') format('woff2'),
+ url('#{$fonts-path}/IBM_Plex_Mono/IBMPlexMono-Regular.woff') format('woff');
+ font-weight: $font-weight-400;
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/src/app/styles/index.scss b/src/app/styles/index.scss
new file mode 100644
index 0000000..c6beee2
--- /dev/null
+++ b/src/app/styles/index.scss
@@ -0,0 +1,37 @@
+@import './vars';
+@import './fonts';
+
+html {
+ color: $color-text-primary;
+ //background-color: $color-surface-primary;
+ background-color: black;
+ //background-color: $color-on-surface-dark-100;
+ font-family: $font-family-raleway, $font-family-default;
+ font-size: $font-size-16;
+ line-height: $line-height-20;
+}
+
+html,
+body {
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-feature-settings: 'pnum' on, 'lnum' on;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p {
+ margin: 0;
+}
+
+a {
+ text-decoration: none;
+ color: inherit;
+}
diff --git a/src/app/styles/mixins.scss b/src/app/styles/mixins.scss
new file mode 100644
index 0000000..352e585
--- /dev/null
+++ b/src/app/styles/mixins.scss
@@ -0,0 +1,220 @@
+@use 'sass:list';
+
+/* positions */
+
+@mixin position-absolute-full-screen {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
+/* size */
+
+@mixin size($size) {
+ min-width: $size;
+ max-width: $size;
+ min-height: $size;
+ max-height: $size;
+}
+
+/* placeholder */
+
+@mixin placeholder {
+ &::placeholder {
+ @content;
+ }
+}
+
+/* buttons */
+
+@mixin reset-button {
+ align-items: flex-start;
+ margin: 0;
+ padding: 0;
+ text-align: center;
+ text-decoration: none;
+ text-indent: 0;
+ letter-spacing: inherit;
+ word-spacing: inherit;
+ text-transform: none;
+ color: inherit;
+ border: none;
+ outline: none;
+ background: none;
+ text-shadow: none;
+ font: inherit;
+ line-height: inherit;
+ cursor: default;
+ box-sizing: border-box;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+@mixin reset-default-input {
+ padding: unset;
+ border: none;
+
+ &:focus {
+ outline: none;
+ outline-offset: initial;
+ }
+}
+
+/* Хак для того, чтобы убрать браузерные autofill стили */
+@mixin remove-autofill-style {
+ &:-webkit-autofill {
+ /* stylelint-disable-next-line */
+ -webkit-background-clip: text;
+ }
+}
+
+/* links */
+
+@mixin link-reset {
+ all: unset;
+}
+
+@mixin focus-visible {
+ /* stylelint-disable-next-line */
+ &:global(.focus-visible) {
+ @content;
+ }
+}
+
+@mixin line-clamp($n) {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-line-clamp: $n;
+ -webkit-box-orient: vertical;
+}
+
+@mixin hide-default-input {
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
+ opacity: 0;
+}
+
+/* stylelint-disable order/order */
+
+@mixin transition($properties...) {
+ $declarations: ();
+
+ @each $declaration in $properties {
+ $duration: 0.2s;
+ $delay: false;
+ $timing-function: ease-out;
+ $property: all;
+
+ @if type-of($declaration) == 'map' {
+ $duration: if(map_get($declaration, 'duration'), #{map_get($declaration, 'duration')}, $duration);
+ $delay: if(map_get($declaration, 'delay'), #{map_get($declaration, 'delay')}, $delay);
+ $timing-function: if(
+ map_get($declaration, 'timing-function'),
+ #{map_get($declaration, 'timing-function')},
+ $timing-function
+ );
+ $property: if(map_get($declaration, 'property'), #{map_get($declaration, 'property')}, $property);
+ } @else {
+ $property: $declaration;
+ }
+
+ $delay-value: if($delay, ' ' + $delay, '');
+ $declarations: list.append(
+ $declarations,
+ #{$property + ' ' + $duration + $delay-value + ' ' + $timing-function},
+ comma
+ );
+ }
+
+ transition: $declarations;
+}
+
+/* stylelint-enable order/order */
+
+@mixin flex {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: flex-start;
+}
+
+@mixin flex-center {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin flex-between {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+@mixin flex-middle {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+@mixin flex-col {
+ display: flex;
+ flex-direction: column;
+}
+
+@mixin flex-col-middle {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+@mixin text-overflow {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+
+/* scrollbar */
+
+$scrollbar-width: 12px;
+
+@mixin scrollbar {
+ &::-webkit-scrollbar {
+ width: $scrollbar-width;
+ height: $scrollbar-width;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ border: 4px solid transparent;
+ border-radius: 6px;
+ background-color: $color-on-surface-light-400;
+ background-clip: content-box;
+ }
+
+ &::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background-color: $color-on-surface-light-300;
+ }
+}
+
+@mixin hide-scrollbar {
+ overflow-y: auto;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none; /* for Chrome, Safari, and Opera */
+ }
+}
\ No newline at end of file
diff --git a/src/app/styles/radius.scss b/src/app/styles/radius.scss
new file mode 100644
index 0000000..94cfb5e
--- /dev/null
+++ b/src/app/styles/radius.scss
@@ -0,0 +1,6 @@
+$radius-none: 0;
+$radius-small: 4px;
+$radius-medium: 8px;
+$radius-large: 12px;
+$radius-large-x: 16px;
+$radius-large-xx: 24px;
diff --git a/src/app/styles/shadow.scss b/src/app/styles/shadow.scss
new file mode 100644
index 0000000..ce1c543
--- /dev/null
+++ b/src/app/styles/shadow.scss
@@ -0,0 +1,7 @@
+$shadow-dropdown: 0 1px 24px 0 rgb(0 0 0 / 40%);
+$shadow-hover: 0 4px 24px 0 rgb(0 0 0 / 24%);
+$shadow-bottom-item: 0 -2px 12px 0 rgb(0 0 0 / 25%);
+$shadow-top: 0 -4px 1px rgb(20 20 20 / 8%), 0 -12px 6px rgb(20 20 20 / 16%), 0 -8px 20px rgb(20 20 20 / 24%);
+$shadow-left: -4px 0 1px rgb(20 20 20 / 8%), -12px 0 6px rgb(20 20 20 / 16%), -8px 0 20px rgb(20 20 20 / 24%);
+$shadow-right: 4px 0 1px rgb(20 20 20 / 8%), 12px 0 6px rgb(20 20 20 / 16%), 8px 0 20px rgb(20 20 20 / 24%);
+$shadow-short: 0 6px 16px -12px rgb(23 40 77 / 6%);
diff --git a/src/app/styles/spacing.scss b/src/app/styles/spacing.scss
new file mode 100644
index 0000000..2ea6835
--- /dev/null
+++ b/src/app/styles/spacing.scss
@@ -0,0 +1,12 @@
+$spacing-small-4x: 4px;
+$spacing-small-3x: 8px;
+$spacing-small-2x: 12px;
+$spacing-small-x: 16px;
+$spacing-small: 24px;
+$spacing-medium: 32px;
+$spacing-medium-x: 40px;
+$spacing-large: 48px;
+$spacing-large-x: 64px;
+$spacing-large-2x: 72px;
+$spacing-large-3x: 96px;
+$spacing-large-4x: 124px;
diff --git a/src/app/styles/vars.scss b/src/app/styles/vars.scss
new file mode 100644
index 0000000..d64e0ca
--- /dev/null
+++ b/src/app/styles/vars.scss
@@ -0,0 +1,8 @@
+@import 'colors';
+@import 'baseText';
+@import 'breakpoints';
+@import 'mixins';
+@import 'radius';
+@import 'spacing';
+@import 'z-index';
+@import 'shadow';
\ No newline at end of file
diff --git a/src/app/styles/z-index.scss b/src/app/styles/z-index.scss
new file mode 100644
index 0000000..de250aa
--- /dev/null
+++ b/src/app/styles/z-index.scss
@@ -0,0 +1,11 @@
+$z-index-behind: -1;
+$z-index-default: 0;
+$z-index-primary: 1;
+$z-index-secondary: 2;
+$z-index-dropdown-menu: 10;
+$z-index-sidebar: 100;
+$z-index-editor: 100;
+$z-index-overlay: 101;
+$z-index-modal: 1000;
+$z-index-notification: 1001;
+$z-index-max: 99999;
diff --git a/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff
new file mode 100644
index 0000000..fbde09c
Binary files /dev/null and b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff differ
diff --git a/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2 b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2
new file mode 100644
index 0000000..64ad179
Binary files /dev/null and b/src/assets/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.woff2 differ
diff --git a/src/assets/fonts/Raleway/Raleway-Bold.woff b/src/assets/fonts/Raleway/Raleway-Bold.woff
new file mode 100644
index 0000000..e21696e
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Bold.woff differ
diff --git a/src/assets/fonts/Raleway/Raleway-Bold.woff2 b/src/assets/fonts/Raleway/Raleway-Bold.woff2
new file mode 100644
index 0000000..e9cc3bf
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Bold.woff2 differ
diff --git a/src/assets/fonts/Raleway/Raleway-Medium.woff b/src/assets/fonts/Raleway/Raleway-Medium.woff
new file mode 100644
index 0000000..4f9ca5f
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Medium.woff differ
diff --git a/src/assets/fonts/Raleway/Raleway-Medium.woff2 b/src/assets/fonts/Raleway/Raleway-Medium.woff2
new file mode 100644
index 0000000..c2d95d8
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Medium.woff2 differ
diff --git a/src/assets/fonts/Raleway/Raleway-Regular.woff b/src/assets/fonts/Raleway/Raleway-Regular.woff
new file mode 100644
index 0000000..8423494
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Regular.woff differ
diff --git a/src/assets/fonts/Raleway/Raleway-Regular.woff2 b/src/assets/fonts/Raleway/Raleway-Regular.woff2
new file mode 100644
index 0000000..18e4f7b
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Regular.woff2 differ
diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.woff b/src/assets/fonts/Raleway/Raleway-SemiBold.woff
new file mode 100644
index 0000000..f13b8e2
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.woff differ
diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 b/src/assets/fonts/Raleway/Raleway-SemiBold.woff2
new file mode 100644
index 0000000..64b8ce4
Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 differ
diff --git a/src/assets/icons/right.svg b/src/assets/icons/right.svg
new file mode 100644
index 0000000..f10aeba
--- /dev/null
+++ b/src/assets/icons/right.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss
new file mode 100644
index 0000000..41e8167
--- /dev/null
+++ b/src/components/Button/Button.module.scss
@@ -0,0 +1,119 @@
+@import 'src/app/styles/vars';
+
+$button-border-width: 2px;
+
+.Button {
+ @include text-body-m-medium;
+ @include transition(color, background-color, border-color);
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: max-content;
+ padding: 0 ($spacing-small - $button-border-width);
+ text-decoration: none;
+ color: $color-text-primary;
+ border: $button-border-width solid transparent;
+ //border-radius: $radius-small;
+ border-radius: $radius-large-x;
+ background-color: rgb(255 255 255 / 0%);
+ box-shadow: none;
+ user-select: none;
+ cursor: pointer;
+ box-sizing: border-box;
+ appearance: none;
+
+ &,
+ &:hover,
+ &:focus {
+ outline: none;
+ }
+}
+
+.Button_disabled {
+ pointer-events: none;
+}
+
+.Button:focus-visible {
+ border-color: $color-brand-hover;
+}
+
+.Button_hasLeft {
+ padding-left: $spacing-small-x - $button-border-width;
+}
+
+.Button_hasRight {
+ padding-right: $spacing-small-x - $button-border-width;
+}
+
+.Button__content {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ text-align: center;
+}
+
+.Button__loader,
+.Button__contentLeft,
+.Button__contentRight {
+ display: flex;
+ align-items: center;
+}
+
+.Button__contentLeft {
+ margin-right: $spacing-small-3x;
+}
+
+.Button__loader,
+.Button__contentRight {
+ margin-left: $spacing-small-3x;
+}
+
+.Button_size_small {
+ height: 32px;
+}
+
+.Button_size_medium {
+ height: 40px;
+}
+
+.Button_size_large {
+ height: 48px;
+}
+
+.Button_variant_primary {
+ background-color: $color-brand-primary;
+
+ &.Button_hovered,
+ &:hover {
+ background-color: $color-brand-hover;
+ }
+
+ &.Button_disabled {
+ color: $color-text-tertiary;
+ background-color: $color-brand-disabled;
+ }
+}
+
+.Button_variant_secondary {
+ background-color: $color-on-surface-dark-300;
+
+ &.Button_hovered,
+ &:hover {
+ background-color: $color-on-surface-dark-400;
+ }
+
+ &.Button_disabled {
+ color: $color-text-quaternary;
+ }
+}
+
+.Button_stretch_fit {
+ width: fit-content;
+}
+
+.Button_stretch_fill {
+ width: 100%;
+}
diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx
new file mode 100644
index 0000000..b46f2ca
--- /dev/null
+++ b/src/components/Button/Button.tsx
@@ -0,0 +1,148 @@
+import React, { ElementType, useMemo } from 'react';
+import clsx from 'clsx';
+import s from './Button.module.scss';
+import {PolyExtends} from '../../utils/types';
+
+export enum ButtonSize {
+ small = 'small',
+ medium = 'medium',
+ large = 'large'
+}
+
+export enum ButtonVariant {
+ primary = 'primary',
+ secondary = 'secondary',
+ tertiary = 'tertiary'
+}
+
+export enum ButtonStretch {
+ fit = 'fit',
+ fill = 'fill'
+}
+
+export const ButtonDefaultComponent = 'button' as const;
+export type ButtonDefaultComponentType = typeof ButtonDefaultComponent;
+
+export interface ButtonSelfProps {
+ /**
+ * Размер ("small", "medium", "large")
+ */
+ size?: ButtonSize;
+ /**
+ * Вариант оформления ("primary", "secondary", "tertiary")
+ */
+ variant?: ButtonVariant;
+ /**
+ * Заблокированное состояние
+ */
+ disabled?: boolean;
+ /**
+ * Проп для контролируемого включения состояния ховера
+ */
+ hovered?: boolean;
+ /**
+ * Цвет фона в hex-формате
+ */
+ color?: string;
+ /**
+ * Состояние со спиннером
+ */
+ isLoading?: boolean;
+ /**
+ * Потомки компонента
+ */
+ children?: React.ReactNode;
+ /**
+ * Реф на корневой DOM-элемент
+ */
+ innerRef?: React.ComponentProps['ref'];
+ /**
+ * Дополнительные css-классы элементов
+ */
+ classes?: {
+ content?: string;
+ contentLeft?: string;
+ contentRight?: string;
+ text?: string;
+ icon?: string;
+ };
+ /**
+ * Вариант растягивания кнопки ("fit", "fill")
+ */
+ stretch?: ButtonStretch;
+}
+
+export type ButtonProps = PolyExtends<
+ ComponentType,
+ ButtonSelfProps,
+ React.ComponentProps
+>;
+
+export function Button({
+ component,
+ className,
+ style,
+ size = ButtonSize.medium,
+ variant = ButtonVariant.primary,
+ disabled,
+ hovered,
+ children,
+ leftIcon,
+ right,
+ left,
+ rightIcon,
+ isLoading,
+ color,
+ innerRef,
+ classes,
+ stretch,
+ ...props
+}: ButtonProps) {
+ // Чтобы ts не проверял корректность передачи пропов
+ const Component = (component || ButtonDefaultComponent) as React.ElementType;
+
+ const buttonProps = useMemo(() => {
+ if (Component === 'button') {
+ return { type: 'button' };
+ }
+ return undefined;
+ }, [Component]);
+
+ const hasLeft = left || leftIcon;
+
+ const hasRight = right || rightIcon;
+
+ return (
+
+
+
{children}
+
+ {/*{isLoading &&
}*/}
+
+
+ );
+}
+
+Button.Variant = ButtonVariant;
+Button.Size = ButtonSize;
diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts
new file mode 100644
index 0000000..8b166a8
--- /dev/null
+++ b/src/components/Button/index.ts
@@ -0,0 +1 @@
+export * from './Button';
diff --git a/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.module.scss b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.module.scss
new file mode 100644
index 0000000..51fab5b
--- /dev/null
+++ b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.module.scss
@@ -0,0 +1,69 @@
+@import 'src/app/styles/vars';
+
+.BaseCheckboxIcon {
+ @include transition(border, background-color);
+
+ position: relative;
+ display: flex;
+ flex: 0 0 16px;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border: 1px solid $color-on-surface-light-300;
+ border-radius: $radius-small;
+ background-color: transparent;
+ box-sizing: border-box;
+}
+
+.BaseCheckboxIcon__icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transform: translate(-50%, -50%);
+}
+
+.BaseCheckboxIcon__icon > * {
+ @include transition(opacity, color);
+
+ color: $color-on-surface-light-100;
+ opacity: 0;
+}
+
+.BaseCheckboxIcon__icon_visible > * {
+ opacity: 1;
+}
+
+.BaseCheckboxIcon_disabled {
+ color: $color-accent-blue-100;
+ border-color: $color-accent-blue-300;
+}
+
+.BaseCheckboxIcon_checked {
+ border-color: transparent;
+ background-color: $color-accent-blue-200;
+
+ &.BaseCheckboxIcon_disabled {
+ background-color: $color-accent-blue-300;
+ }
+}
+
+.BaseCheckboxIcon_radio {
+ border-radius: 50%;
+
+ &.BaseCheckboxIcon_checked {
+ border: 4px solid $color-accent-blue-200;
+ background-color: $color-on-surface-light-100;
+ }
+
+ &.BaseCheckboxIcon_disabled {
+ border-color: $color-accent-blue-300;
+ }
+
+ &.BaseCheckboxIcon_checked.BaseCheckboxIcon_disabled {
+ background-color: $color-accent-blue-100;
+ }
+}
diff --git a/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.tsx b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.tsx
new file mode 100644
index 0000000..e566050
--- /dev/null
+++ b/src/components/Checkbox/BaseCheckboxIcon/BaseCheckboxIcon.tsx
@@ -0,0 +1,49 @@
+import React, { FunctionComponent } from 'react';
+import clsx from 'clsx';
+import s from './BaseCheckboxIcon.module.scss';
+import {DivPropsWithoutRef} from '../../../utils/types';
+
+export interface BaseCheckboxIconProps extends DivPropsWithoutRef {
+ /**
+ * Состояние Checkbox: включен или выключен
+ */
+ checked?: boolean;
+ /**
+ * Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя
+ */
+ disabled?: boolean;
+ /**
+ * Свойство, устанавливающие тип radio для BaseCheckboxIcon
+ */
+ radio?: boolean;
+}
+
+export const BaseCheckboxIcon: FunctionComponent = ({
+ className,
+ checked,
+ disabled,
+ radio,
+ children,
+ ...props
+}) => {
+ return (
+
+ );
+};
diff --git a/src/components/Checkbox/BaseCheckboxIcon/index.ts b/src/components/Checkbox/BaseCheckboxIcon/index.ts
new file mode 100644
index 0000000..912e528
--- /dev/null
+++ b/src/components/Checkbox/BaseCheckboxIcon/index.ts
@@ -0,0 +1 @@
+export * from './BaseCheckboxIcon';
diff --git a/src/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss
new file mode 100644
index 0000000..1aaa64c
--- /dev/null
+++ b/src/components/Checkbox/Checkbox.module.scss
@@ -0,0 +1,81 @@
+@import 'src/app/styles/vars';
+
+.Checkbox {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ vertical-align: middle;
+ user-select: none;
+ cursor: pointer;
+
+ &,
+ &:focus {
+ outline: none;
+ }
+}
+
+.Checkbox__input {
+ @include hide-default-input;
+}
+
+.Checkbox__label {
+ @include text-caption-m-regular;
+ @include transition(color);
+
+ display: inline-flex;
+ margin-left: $spacing-small-3x;
+ vertical-align: middle;
+ color: $color-text-primary;
+}
+
+.Checkbox__icon {
+ &::before {
+ @include transition(border-color);
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 32px;
+ height: 32px;
+ content: '';
+ border: 2px solid transparent;
+ border-radius: $radius-medium;
+ transform: translate(-50%, -50%);
+ }
+}
+
+.Checkbox__input:focus-visible + .Checkbox__icon {
+ &::before {
+ border-color: $color-brand-hover;
+ }
+}
+
+.Checkbox:hover .Checkbox__icon {
+ border-color: $color-accent-blue-100;
+}
+
+.Checkbox_disabled {
+ cursor: default;
+ pointer-events: none;
+
+ .Checkbox__label {
+ color: $color-text-tertiary;
+ }
+}
+
+.Checkbox_checked {
+ &:hover {
+ .Checkbox__icon:not(.Checkbox__icon_radio) {
+ background-color: $color-accent-blue-100;
+ }
+
+ .Checkbox__icon_radio {
+ border-color: $color-accent-blue-100;
+ }
+ }
+}
+
+.Checkbox_radio.Checkbox_checked.Checkbox_disabled:hover .BaseCheckboxIcon_radio {
+ border: 6px solid brown;
+ background-color: transparent;
+}
diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx
new file mode 100644
index 0000000..f0a5ad8
--- /dev/null
+++ b/src/components/Checkbox/Checkbox.tsx
@@ -0,0 +1,121 @@
+import React, { ChangeEvent, FunctionComponent, ReactNode } from 'react';
+import clsx from 'clsx';
+import s from './Checkbox.module.scss';
+import {InputPropsWithoutRef, IntrinsicPropsWithoutRef} from '../../utils/types';
+import {BaseCheckboxIcon} from './BaseCheckboxIcon';
+
+export interface ICheckboxProps extends Omit {
+ /**
+ * Состояние Checkbox: включен или выключен
+ */
+ checked?: boolean;
+ /**
+ * Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя
+ */
+ disabled?: boolean;
+ /**
+ * Если true, то компонент аналогичен компоненту Radio
+ */
+ radio?: boolean;
+ /**
+ * Слот подписи
+ */
+ label?: ReactNode;
+ /**
+ * Обработчик изменения состояния Checkbox. Принимает на вход новое значение состояния Checkbox (в случае, если checked, то новое значение - false, иначе - true) и ChangeEvent
+ */
+ onChange?: (value: boolean, e: ChangeEvent) => void;
+ /**
+ * Слот для замены иконки чекбокса
+ */
+ checkboxIcon?: React.ReactNode;
+ /**
+ * Дополнительные css-классы элементов
+ * * input – класс элемента input
+ * * label – класс лейбла
+ * * icon – класс иконки чекбокса
+ */
+ classes?: {
+ input?: string;
+ label?: string;
+ icon?: string;
+ };
+ /**
+ * Дополнительный css-класс корневого элемента
+ */
+ className?: string;
+ /**
+ * Реф на корневой элемент
+ */
+ innerRef?: React.Ref;
+ /**
+ * Реф на input
+ */
+ inputRef?: React.Ref;
+ /**
+ * Дополнительные пропы корневого элемента
+ */
+ rootProps?: IntrinsicPropsWithoutRef<'label'>;
+}
+
+export const Checkbox: FunctionComponent = ({
+ className,
+ checked,
+ disabled,
+ radio,
+ label,
+ onChange,
+ style,
+ checkboxIcon,
+ classes,
+ innerRef,
+ inputRef,
+ rootProps,
+ ...props
+}) => {
+ const handleChange = onChange
+ ? (e: ChangeEvent) => {
+ onChange(!checked, e);
+ }
+ : undefined;
+
+ return (
+
+ );
+};
diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts
new file mode 100644
index 0000000..f5c939f
--- /dev/null
+++ b/src/components/Checkbox/index.ts
@@ -0,0 +1 @@
+export * from './Checkbox';
diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx
new file mode 100644
index 0000000..3695e7b
--- /dev/null
+++ b/src/components/Form/Form.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { useForm, UseFormReturn, SubmitHandler, UseFormProps, FieldValues } from 'react-hook-form';
+
+type FormProps = {
+ className?: string;
+ onSubmit?: SubmitHandler;
+ children: (methods: UseFormReturn) => React.ReactNode;
+ options?: UseFormProps;
+ id?: string;
+ innerRef?: React.Ref;
+};
+
+export const Form = ({
+ onSubmit,
+ children,
+ className,
+ options,
+ id,
+ innerRef
+}: FormProps) => {
+ const methods = useForm({ ...options });
+
+ return (
+ //
+ );
+};
diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts
new file mode 100644
index 0000000..511d53b
--- /dev/null
+++ b/src/components/Form/index.ts
@@ -0,0 +1 @@
+export * from './Form';
\ No newline at end of file
diff --git a/src/components/Hint/Hint.module.scss b/src/components/Hint/Hint.module.scss
new file mode 100644
index 0000000..d03c0f3
--- /dev/null
+++ b/src/components/Hint/Hint.module.scss
@@ -0,0 +1,83 @@
+@import 'src/app/styles/vars';
+
+.Hint__container {
+ position: relative;
+ width: fit-content;
+ height: 44px;
+
+ @include media-down(tablet-small) {
+ width: 100%;
+ }
+}
+
+.Hint__container_hovered {
+ height: unset;
+}
+
+.Hint {
+ @include transition(background-color, color, width);
+ position: relative;
+ //display: inline-block;
+ padding: $spacing-small-2x;
+ border-radius: $radius-large-x;
+ background-color: $color-on-surface-light-400;
+ //margin-right: $spacing-small;
+ width: fit-content;
+ color: $color-text-secondary;
+ cursor: pointer;
+
+ &:not(.Hint_hovered) {
+ white-space: nowrap;
+ max-width: 500px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @include media-down(tablet-small) {
+ width: 100%;
+ max-width: 100%;
+ }
+ }
+}
+
+.Hint_hovered {
+ width: 100%;
+ background-color: $color-brand-hover;
+ color: white;
+}
+
+.Hint__tip {
+ @include text-caption-m-medium;
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ background-color: white;
+ z-index: 1;
+ padding: 2px 4px;
+ width: 16px;
+ height: 16px;
+ border-radius: 20px;
+}
+
+.Hint__button {
+ width: 100% !important;
+ margin-top: $spacing-small-2x;
+}
+
+.Hints {
+ @include flex;
+ flex-wrap: wrap;
+ gap: $spacing-small-x;
+
+ @include media-down(tablet-small) {
+ flex-direction: column;
+ }
+}
+
+.Hints_margin {
+ margin-bottom: $spacing-small;
+}
+
+.Hints__stub {
+ @include text-body-s-regular;
+ color: $color-text-secondary;
+}
diff --git a/src/components/Hint/Hint.tsx b/src/components/Hint/Hint.tsx
new file mode 100644
index 0000000..200732c
--- /dev/null
+++ b/src/components/Hint/Hint.tsx
@@ -0,0 +1,72 @@
+import clsx from 'clsx';
+import s from './Hint.module.scss';
+import {ReactFCC} from '../../utils/ReactFCC';
+import {FC} from 'react';
+import {useHover} from '../../hooks/useHover';
+import {useIsMobile} from '../../hooks/useIsMobile';
+import {useToggle} from '../../hooks/useToggle';
+import {Button, ButtonVariant} from '../Button';
+
+export interface HintProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ children: string;
+ onClick?: () => void;
+}
+
+export const Hint: FC = (props) => {
+ const {children, className, onClick: onClickProp} = props;
+
+ const isMobile = useIsMobile();
+
+ const [hoveredMobile, { toggle }] = useToggle();
+
+ const { hovered: hoveredDesktop, ...hoverProps } = useHover();
+
+ const hintProps = isMobile ? {} : hoverProps;
+
+ const onClick = () => {
+ if (isMobile) {
+ toggle()
+ }
+ }
+
+ const hovered = isMobile ? hoveredMobile : hoveredDesktop;
+
+ return (
+ {
+ if (!isMobile) {
+ onClickProp?.();
+ }
+ }}>
+
+
+ {children}
+
+ {isMobile && hovered && (
+
+ )}
+
+
+ );
+};
+
+export interface HintsContainerProps {
+ isLoading?: boolean;
+ margin?: boolean;
+}
+
+export const HintsContainer: ReactFCC = ({ children, isLoading, margin }) => {
+ return (
+ {!isLoading ? children : (
+
Загрузка подсказок...
+ )}
+ )
+}
diff --git a/src/components/Hint/index.ts b/src/components/Hint/index.ts
new file mode 100644
index 0000000..46ec2ce
--- /dev/null
+++ b/src/components/Hint/index.ts
@@ -0,0 +1 @@
+export * from './Hint';
\ No newline at end of file
diff --git a/src/components/Input/Input.module.scss b/src/components/Input/Input.module.scss
new file mode 100644
index 0000000..e240473
--- /dev/null
+++ b/src/components/Input/Input.module.scss
@@ -0,0 +1,130 @@
+@import 'src/app/styles/vars';
+
+.Input {
+ @include text-body-m-regular;
+ @include transition(border-color, box-shadow);
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ height: 40px;
+ padding: 0 $spacing-small-x;
+ color: $color-text-primary;
+ overflow: hidden;
+ border: 1px solid transparent;
+ border-radius: $radius-small;
+ background-color: $color-on-surface-dark-400;
+ cursor: text;
+ box-sizing: border-box;
+
+ &:hover {
+ box-shadow: $shadow-hover;
+ }
+}
+
+.Input_error {
+ border-color: $color-system-error;
+}
+
+.Input_focus {
+ border-color: $color-brand-primary;
+}
+
+.Input_disabled {
+ color: $color-text-tertiary;
+ border-color: transparent;
+ background: $color-on-surface-dark-300;
+ cursor: default;
+
+ &:hover {
+ box-shadow: none;
+ }
+}
+
+.Input__input {
+ @include reset-default-input;
+ @include remove-autofill-style;
+
+ position: relative;
+ flex-grow: 1;
+ min-width: 0;
+ height: 100%;
+
+ &:placeholder-shown + .Input__clear {
+ display: none;
+ }
+
+ &[type='number'] {
+ appearance: textfield;
+ }
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ appearance: none;
+ }
+
+ &:-webkit-autofill,
+ &:-webkit-autofill:hover,
+ &:-webkit-autofill:focus,
+ &:-webkit-autofill:active {
+ caret-color: $color-text-primary;
+ -webkit-text-fill-color: $color-text-primary;
+ }
+}
+
+.Input__input_disabled {
+ cursor: default;
+}
+
+.Input__input::placeholder {
+ color: $color-text-tertiary;
+ opacity: 1;
+}
+
+.Input__icon {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ max-height: 100%;
+ color: $color-on-surface-light-300;
+}
+
+.Input__icon_active {
+ @include transition(color);
+
+ cursor: pointer;
+
+ &:hover {
+ color: $color-on-surface-light-200;
+ }
+}
+
+.Input__icon_left {
+ justify-content: flex-start;
+ margin-right: $spacing-small-3x;
+ margin-left: -$spacing-small-3x;
+}
+
+.Input__icon_right {
+ justify-content: flex-end;
+ margin-left: $spacing-small-3x;
+
+ &:last-child {
+ margin-right: -$spacing-small-3x;
+ }
+}
+
+.Input__icon_clear {
+ @include transition(color);
+
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ color: $color-on-surface-light-200;
+ }
+}
+
+.Input_disabled .Input__icon_clear {
+ pointer-events: none;
+}
diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx
new file mode 100644
index 0000000..b91dc7a
--- /dev/null
+++ b/src/components/Input/Input.tsx
@@ -0,0 +1,103 @@
+import React, {ChangeEvent, useCallback, useRef, useState} from 'react';
+import clsx from 'clsx';
+import composeRefs from '@seznam/compose-react-refs';
+import s from './Input.module.scss';
+import {IntrinsicPropsWithoutRef} from '../../utils/types';
+import {useDelegateFocus} from '../../hooks/useDelegateFocus';
+import {useFocus} from '../../hooks/useFocus';
+import {useLiveInput} from '../../hooks/useLiveInput';
+import {UseFormRegisterReturn} from 'react-hook-form';
+
+export interface InputProps extends Omit, 'onClick'> {
+ /**
+ * Состояние ошибки
+ */
+ error?: boolean;
+ /**
+ * Проп для контролируемого включения состояния фокуса
+ */
+ focused?: boolean;
+ /**
+ * Ref на input-элемент
+ */
+ inputRef?: React.Ref;
+ /**
+ * Обработчик нажатия на Input
+ */
+ onClick?: (event: React.MouseEvent) => void;
+ /**
+ * Дополнительные css-классы элементов:
+ * * root - внешний контейнер
+ * * input - элемент input
+ * * icon - иконки слева и справа Input
+ * * iconLeft - иконка слева Input
+ * * iconRight - иконка справа Input
+ */
+ classes?: {
+ root?: string;
+ icon?: string;
+ iconLeft?: string;
+ iconRight?: string;
+ input?: string;
+ };
+ registration?: Partial;
+}
+
+export type InputType = React.ForwardRefExoticComponent>;
+
+const InputForwardedRef = React.forwardRef(
+ (
+ {
+ error,
+ focused: focusedProp,
+ className,
+ classes,
+ onClick,
+ onInput,
+ inputRef: inputRefProp,
+ style,
+ type,
+ value,
+ registration,
+ ...inputProps
+ },
+ ref
+ ) => {
+ const inputRef = useRef(null);
+
+ const delegateProps = useDelegateFocus(inputRef, { onClick });
+ const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration });
+
+ return (
+
+
+
+ );
+ }
+);
+
+InputForwardedRef.displayName = 'Input';
+
+export const Input: InputType = InputForwardedRef;
diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts
new file mode 100644
index 0000000..ba9fe7e
--- /dev/null
+++ b/src/components/Input/index.ts
@@ -0,0 +1 @@
+export * from './Input';
diff --git a/src/components/Message/Message.module.scss b/src/components/Message/Message.module.scss
new file mode 100644
index 0000000..c21fb5d
--- /dev/null
+++ b/src/components/Message/Message.module.scss
@@ -0,0 +1,29 @@
+@import 'src/app/styles/vars';
+
+//$message-radius: $radius-large;
+$message-radius: $radius-large-xx;
+
+.Message {
+ @include text-body-l-regular;
+
+ padding: $spacing-small-x;
+ box-shadow: $shadow-short;
+ white-space: pre-wrap;
+}
+
+.Message_variant_primary {
+ background-color: $color-brand-primary;
+}
+
+.Message_variant_secondary {
+ background-color: $color-on-surface-dark-400;
+ color: #d0d1d5;
+}
+
+.Message_type_right {
+ border-radius: $message-radius $message-radius 0 $message-radius;
+}
+
+.Message_type_left {
+ border-radius: $message-radius $message-radius $message-radius 0;
+}
\ No newline at end of file
diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx
new file mode 100644
index 0000000..bb115ba
--- /dev/null
+++ b/src/components/Message/Message.tsx
@@ -0,0 +1,33 @@
+import clsx from 'clsx';
+import s from './Message.module.scss';
+import {ReactFCC} from '../../utils/ReactFCC';
+
+export enum MessageVariant {
+ primary = 'primary',
+ secondary = 'secondary'
+}
+
+export enum MessageType {
+ right = 'right',
+ left = 'left'
+}
+
+export interface MessageProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ variant?: MessageVariant;
+ type: MessageType;
+}
+
+export const Message: ReactFCC = (props) => {
+ const { children, className, variant, type } = props;
+
+ return (
+
+ {children}
+
+ );
+};
+
diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts
new file mode 100644
index 0000000..3dd21cb
--- /dev/null
+++ b/src/components/Message/index.ts
@@ -0,0 +1 @@
+export * from './Message';
\ No newline at end of file
diff --git a/src/components/Radio/Radio.tsx b/src/components/Radio/Radio.tsx
new file mode 100644
index 0000000..5f3a029
--- /dev/null
+++ b/src/components/Radio/Radio.tsx
@@ -0,0 +1,19 @@
+import React, { ChangeEvent, FunctionComponent } from 'react';
+import {Checkbox, ICheckboxProps} from '../Checkbox';
+
+export interface RadioProps extends Omit {
+ /**
+ * Обработчик изменения значения Radio. Принимает на вход значение Radio и ChangeEvent
+ */
+ onChange?: (value: string, e: ChangeEvent) => void;
+}
+
+export const Radio: FunctionComponent = ({ onChange, ...props }) => {
+ const handleChange = onChange
+ ? (value: boolean, event: ChangeEvent) => {
+ onChange(event.target.value, event);
+ }
+ : undefined;
+
+ return ;
+};
diff --git a/src/components/Radio/index.tsx b/src/components/Radio/index.tsx
new file mode 100644
index 0000000..bfbe6d0
--- /dev/null
+++ b/src/components/Radio/index.tsx
@@ -0,0 +1 @@
+export * from './Radio';
diff --git a/src/components/Range/Range.module.scss b/src/components/Range/Range.module.scss
new file mode 100644
index 0000000..a9b7bfd
--- /dev/null
+++ b/src/components/Range/Range.module.scss
@@ -0,0 +1,132 @@
+@import 'src/app/styles/vars';
+
+$range-track-height: 10px;
+$range-thumb-height: 24px;
+
+@mixin range-track {
+ @include transition(background);
+
+ background: $color-on-surface-dark-400;
+ height: $range-track-height;
+ border-radius: $range-track-height;
+
+ &:hover {
+ background: $color-on-surface-light-400;
+ }
+}
+
+@mixin range-thumb {
+ @include transition(background);
+
+ width: $range-thumb-height;
+ height: $range-thumb-height;
+ margin-top: -($range-thumb-height / 2 - $range-track-height / 2);
+ border-radius: 50%;
+ background: $color-brand-primary;
+
+
+ &:hover {
+ background: $color-brand-hover;
+ }
+}
+
+.Range {
+ @include flex-col;
+ gap: $spacing-small-3x;
+}
+
+.Range__label {
+
+}
+
+.Range__container {
+ @include flex-middle;
+ column-gap: $spacing-small-3x;
+}
+
+.Range__inputContainer {
+ position: relative;
+ height: $range-thumb-height;
+ flex: 1;
+}
+
+.Range__input {
+ display: block;
+ bottom: -7px;
+ position: relative;
+ width: 100%;
+ z-index: 1;
+
+ // reset styles
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ cursor: pointer;
+
+ // track
+ &::-webkit-slider-runnable-track {
+ @include range-track;
+ }
+
+ &::-moz-range-track {
+ @include range-track;
+ }
+
+ // thumb
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none; /* Override default look */
+ appearance: none;
+ @include range-thumb;
+ }
+
+ &::-moz-range-thumb {
+ border: none; /*Removes extra border that FF applies*/
+ border-radius: 0; /*Removes default border-radius that FF applies*/
+ @include range-thumb;
+ }
+}
+
+.Range__inputContainer:hover {
+ .Range__input::-webkit-slider-thumb {
+ background: $color-brand-hover;
+ }
+
+ .Range__input::-moz-range-thumb {
+ background: $color-brand-hover;
+ }
+
+ .Range__input::-webkit-slider-runnable-track {
+ background: $color-on-surface-light-400;
+ }
+
+ .Range__input::-moz-range-track {
+ background: $color-on-surface-light-400;
+ }
+}
+
+.Range__progress {
+ @include transition(background);
+ position: absolute;
+ background: $color-brand-primary;
+ height: $range-track-height;
+ border-radius: $range-track-height 0 0 $range-track-height;
+ top: 7px;
+ left: 0;
+ cursor: pointer;
+ pointer-events: none;
+ z-index: 2;
+}
+
+.Range__inputContainer:hover .Range__progress {
+ background: $color-brand-hover;
+}
+
+.Range__tip {
+ @include text-caption-m-regular;
+ color: $color-text-secondary;
+}
+
+.Range__tip_active {
+ color: $color-text-brand;
+ min-width: 25px;
+}
\ No newline at end of file
diff --git a/src/components/Range/Range.tsx b/src/components/Range/Range.tsx
new file mode 100644
index 0000000..cf99d40
--- /dev/null
+++ b/src/components/Range/Range.tsx
@@ -0,0 +1,55 @@
+import clsx from 'clsx';
+import s from './Range.module.scss';
+import {ReactFCC} from '../../utils/ReactFCC';
+import {ChangeEvent, useMemo} from 'react';
+import {InputPropsWithoutRef} from '../../utils/types';
+import {currencyFormatter} from '../../utils/fomat';
+
+export interface RangeProps extends Omit {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ label?: string;
+ value: number;
+ onChange: (value: number, e: any) => void;
+ format?: boolean;
+}
+
+export const Range: ReactFCC = (props) => {
+ const {className, min = 0, max = 100, label, value, format, onChange: onChangeProp, ...rest } = props;
+
+ const onChange = (e: ChangeEvent) => {
+ const val = Number(e.target.value);
+ onChangeProp?.(val, e);
+ }
+
+ const percent = useMemo(() => {
+ const percent = ((Number(value) - Number(min)) / (Number(max) - Number(min))) * 100;
+ if (percent < 0) {
+ return 0;
+ } else if (percent > 100) {
+ return 100;
+ }
+
+ return percent;
+ }, [max, min, value]);
+
+ return (
+
+
{label}
+
+
+
{format ? currencyFormatter.format(min as number) : min}
+
+
+
+
{format ? currencyFormatter.format(value as number) : value}
+
+
+ );
+};
+
diff --git a/src/components/Range/index.ts b/src/components/Range/index.ts
new file mode 100644
index 0000000..efb484c
--- /dev/null
+++ b/src/components/Range/index.ts
@@ -0,0 +1 @@
+export * from './Range';
\ No newline at end of file
diff --git a/src/components/Textarea/Textarea.module.scss b/src/components/Textarea/Textarea.module.scss
new file mode 100644
index 0000000..cd187e6
--- /dev/null
+++ b/src/components/Textarea/Textarea.module.scss
@@ -0,0 +1,136 @@
+@import 'src/app/styles/vars';
+
+.Input {
+ @include text-body-m-regular;
+ @include transition(border-color, box-shadow);
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ min-height: 50px;
+ padding: 15px $spacing-small-x;
+ color: $color-text-primary;
+ overflow: hidden;
+ border: 1px solid transparent;
+ border-radius: $radius-small;
+ background-color: $color-on-surface-dark-400;
+ cursor: text;
+ box-sizing: border-box;
+
+ overflow-y: auto;
+ word-wrap: break-word;
+
+ &:hover {
+ box-shadow: $shadow-hover;
+ }
+}
+
+.Input_error {
+ border-color: $color-system-error;
+}
+
+.Input_focus {
+ border-color: $color-brand-primary;
+}
+
+.Input_disabled {
+ color: $color-text-tertiary;
+ border-color: transparent;
+ background: $color-on-surface-dark-300;
+ cursor: default;
+
+ &:hover {
+ box-shadow: none;
+ }
+}
+
+.Input__input {
+ @include reset-default-input;
+ @include remove-autofill-style;
+
+ position: relative;
+ flex-grow: 1;
+ min-width: 0;
+ height: 100%;
+
+ resize: none;
+ white-space: break-spaces;
+
+ &:placeholder-shown + .Input__clear {
+ display: none;
+ }
+
+ &[type='number'] {
+ appearance: textfield;
+ }
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ appearance: none;
+ }
+
+ &:-webkit-autofill,
+ &:-webkit-autofill:hover,
+ &:-webkit-autofill:focus,
+ &:-webkit-autofill:active {
+ caret-color: $color-text-primary;
+ -webkit-text-fill-color: $color-text-primary;
+ }
+}
+
+.Input__input_disabled {
+ cursor: default;
+}
+
+.Input__input::placeholder {
+ color: $color-text-tertiary;
+ opacity: 1;
+}
+
+.Input__icon {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ max-height: 100%;
+ color: $color-on-surface-light-300;
+}
+
+.Input__icon_active {
+ @include transition(color);
+
+ cursor: pointer;
+
+ &:hover {
+ color: $color-on-surface-light-200;
+ }
+}
+
+.Input__icon_left {
+ justify-content: flex-start;
+ margin-right: $spacing-small-3x;
+ margin-left: -$spacing-small-3x;
+}
+
+.Input__icon_right {
+ justify-content: flex-end;
+ margin-left: $spacing-small-3x;
+
+ &:last-child {
+ margin-right: -$spacing-small-3x;
+ }
+}
+
+.Input__icon_clear {
+ @include transition(color);
+
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ color: $color-on-surface-light-200;
+ }
+}
+
+.Input_disabled .Input__icon_clear {
+ pointer-events: none;
+}
diff --git a/src/components/Textarea/Textarea.tsx b/src/components/Textarea/Textarea.tsx
new file mode 100644
index 0000000..2901b77
--- /dev/null
+++ b/src/components/Textarea/Textarea.tsx
@@ -0,0 +1,113 @@
+import React, {useEffect, useRef} from 'react';
+import clsx from 'clsx';
+import composeRefs from '@seznam/compose-react-refs';
+import s from './Textarea.module.scss';
+import {IntrinsicPropsWithoutRef} from '../../utils/types';
+import {useDelegateFocus} from '../../hooks/useDelegateFocus';
+import {useFocus} from '../../hooks/useFocus';
+import {UseFormRegisterReturn} from 'react-hook-form';
+
+export interface TextAreaProps extends Omit, 'onClick' | 'onBlur'> {
+ /**
+ * Состояние ошибки
+ */
+ error?: boolean;
+ /**
+ * Проп для контролируемого включения состояния фокуса
+ */
+ focused?: boolean;
+ /**
+ * Ref на input-элемент
+ */
+ inputRef?: React.Ref;
+ /**
+ * Обработчик нажатия на Input
+ */
+ onClick?: (event: React.MouseEvent) => void;
+ /**
+ * Дополнительные css-классы элементов:
+ * * root - внешний контейнер
+ * * input - элемент input
+ * * icon - иконки слева и справа Input
+ * * iconLeft - иконка слева Input
+ * * iconRight - иконка справа Input
+ */
+ classes?: {
+ root?: string;
+ icon?: string;
+ iconLeft?: string;
+ iconRight?: string;
+ input?: string;
+ };
+ registration?: Partial;
+}
+
+export type InputType = React.ForwardRefExoticComponent>;
+
+const TextareaForwardedRef = React.forwardRef(
+ (
+ {
+ error,
+ focused: focusedProp,
+ className,
+ classes,
+ onClick,
+ onInput,
+ inputRef: inputRefProp,
+ style,
+ registration,
+ ...inputProps
+ },
+ ref
+ ) => {
+ const inputRef = useRef(null);
+
+ const delegateProps = useDelegateFocus(inputRef, { onClick });
+ const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration });
+
+ if (inputRef.current) {
+ inputRef.current.style.height = '1px';
+ inputRef.current.style.height = inputRef.current.scrollHeight + 'px';
+ }
+
+ return (
+
+
+ );
+ }
+);
+
+TextareaForwardedRef.displayName = 'Input';
+
+export const Textarea: InputType = TextareaForwardedRef;
diff --git a/src/components/Textarea/index.ts b/src/components/Textarea/index.ts
new file mode 100644
index 0000000..6449a41
--- /dev/null
+++ b/src/components/Textarea/index.ts
@@ -0,0 +1 @@
+export * from './Textarea';
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..d32ec8e
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,2 @@
+export const API_URL = 'http://192.168.83.181:8000/api';
+// export const API_URL = 'http://192.168.22.4:8000/api';
\ No newline at end of file
diff --git a/src/hooks/useDelegateFocus.ts b/src/hooks/useDelegateFocus.ts
new file mode 100644
index 0000000..494e694
--- /dev/null
+++ b/src/hooks/useDelegateFocus.ts
@@ -0,0 +1,32 @@
+import React, { useCallback } from 'react';
+
+/**
+ * Прокидывает фокус на targetRef при взаимодействии с элементом
+ * @param targetRef - реф элемента, куда прокидывать клик
+ * @param props
+ * @param props.onClick - внешний обработчик клика
+ * @returns
+ * - onClick - новый обработчик клика для элемента
+ */
+export const useDelegateFocus = (
+ targetRef: React.MutableRefObject,
+ {
+ onClick: onClickProp
+ }: {
+ onClick?: React.MouseEventHandler;
+ } = {}
+) => {
+ const onClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (targetRef.current && event.target !== targetRef.current) {
+ targetRef.current.focus();
+ }
+ onClickProp?.(event);
+ },
+ [onClickProp, targetRef]
+ );
+
+ return {
+ onClick
+ };
+};
diff --git a/src/hooks/useFactoryRef.ts b/src/hooks/useFactoryRef.ts
new file mode 100644
index 0000000..39bb5cc
--- /dev/null
+++ b/src/hooks/useFactoryRef.ts
@@ -0,0 +1,13 @@
+import { useRef } from 'react';
+
+/**
+ * Единожды вызывает функцию и сохраняет ее значение в реф
+ * @param factory - функция, создающая значение рефа
+ */
+export function useFactoryRef(factory: () => T): T {
+ const ref = useRef(null);
+ if (!ref.current) {
+ ref.current = factory();
+ }
+ return ref.current;
+}
diff --git a/src/hooks/useFocus.ts b/src/hooks/useFocus.ts
new file mode 100644
index 0000000..274d9ef
--- /dev/null
+++ b/src/hooks/useFocus.ts
@@ -0,0 +1,55 @@
+import { FocusEvent, FocusEventHandler, useState } from 'react';
+
+import { useFactoryRef } from './useFactoryRef';
+import { usePropsRef } from './usePropsRef';
+
+export interface IUseFocusProps {
+ /**
+ * Внешний обработчик onFocus
+ */
+ onFocus?: FocusEventHandler;
+ /**
+ * Внешний обработчик onBlur
+ */
+ onBlur?: FocusEventHandler;
+}
+
+/**
+ * Позволяет использовать focused как состояние компонента.
+ * Активируется, когда элемент или его потомки становятся focus-visible
+ *
+ * @returns
+ * - focused - состояние фокуса (true если :focus-visible)
+ * - onFocus, onBlur - обработчики, которые надо повесить на элемент
+ */
+export function useFocus({ onFocus, onBlur }: IUseFocusProps = {}) {
+ const [focused, setFocused] = useState(false);
+
+ /**
+ * Используем useRef вместо useCallback, чтобы прервать каскад ререндеров
+ * при смене onFocus/onBlur в пропах хука
+ */
+
+ const propsRef = usePropsRef>({
+ onFocus,
+ onBlur
+ });
+ const funcsRef = useFactoryRef(() => ({
+ onFocus: (e: FocusEvent) => {
+ if (e.target.classList.contains('focus-visible')) {
+ setFocused(true);
+ }
+ propsRef.onFocus?.(e);
+ },
+ onBlur: (e: FocusEvent) => {
+ setFocused(false);
+ propsRef.onBlur?.(e);
+ }
+ }));
+
+ return {
+ focused,
+ onFocus: funcsRef.onFocus,
+ onBlur: funcsRef.onBlur
+ };
+}
diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts
new file mode 100644
index 0000000..9deb293
--- /dev/null
+++ b/src/hooks/useHover.ts
@@ -0,0 +1,52 @@
+import { MouseEvent, MouseEventHandler, useState } from 'react';
+
+import { useFactoryRef } from './useFactoryRef';
+import { usePropsRef } from './usePropsRef';
+
+export interface IUseHoverProps {
+ /**
+ * Внешний обработчик onMouseOver
+ */
+ onMouseOver?: MouseEventHandler;
+ /**
+ * Внешний обработчик onMouseOut
+ */
+ onMouseOut?: MouseEventHandler;
+}
+
+/**
+ * Позволяет использовать hovered как состояние компонента.
+ *
+ * @returns
+ * - hovered - состояние фокуса
+ * - onMouseOver, onMouseOut - обработчики, которые надо повесить на элемент
+ */
+export function useHover({ onMouseOver, onMouseOut }: IUseHoverProps = {}) {
+ const [hovered, setHovered] = useState(false);
+
+ /**
+ * Используем useRef вместо useCallback, чтобы прервать каскад ререндеров
+ * при смене onMouseOver/onMouseOut в пропах хука
+ */
+ const propsRef = usePropsRef>({
+ onMouseOver,
+ onMouseOut
+ });
+
+ const funcsRef = useFactoryRef(() => ({
+ onMouseOver: (e: MouseEvent) => {
+ setHovered(true);
+ propsRef.onMouseOver?.(e);
+ },
+ onMouseOut: (e: MouseEvent) => {
+ setHovered(false);
+ propsRef.onMouseOut?.(e);
+ }
+ }));
+
+ return {
+ hovered,
+ onMouseOver: funcsRef.onMouseOver,
+ onMouseOut: funcsRef.onMouseOut
+ };
+}
diff --git a/src/hooks/useIsDesktop.ts b/src/hooks/useIsDesktop.ts
new file mode 100644
index 0000000..c4c5d15
--- /dev/null
+++ b/src/hooks/useIsDesktop.ts
@@ -0,0 +1,3 @@
+import {mediaQuery, useMediaQueryResult} from './useMediaQueryResult';
+
+export const useIsDesktop = () => useMediaQueryResult(mediaQuery.tabletSmallDown);
diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts
new file mode 100644
index 0000000..27e0592
--- /dev/null
+++ b/src/hooks/useIsMobile.ts
@@ -0,0 +1,3 @@
+import {mediaQuery, useMediaQueryResult} from './useMediaQueryResult';
+
+export const useIsMobile = () => useMediaQueryResult(mediaQuery.tabletSmallDown);
diff --git a/src/hooks/useLatestCallbackRef.ts b/src/hooks/useLatestCallbackRef.ts
new file mode 100644
index 0000000..8b01284
--- /dev/null
+++ b/src/hooks/useLatestCallbackRef.ts
@@ -0,0 +1,19 @@
+import { useRef } from "react";
+import { useFactoryRef } from './useFactoryRef';
+
+/**
+ * Позволяет обернуть меняющийся колбек в неменяющйеся с помощью рефа
+ * @param callback - колбек
+ * @returns постоянная функция, которая вызывает последний переданный колбек
+ * @example
+ * const t = new Date();
+ * const onClick = useLatestCallbackRef(() => console.log(t));
+ * onClick -> постоянная функция, которая вызывает последний переданный колбек
+ */
+export function useLatestCallbackRef(
+ callback: (...args: Args) => Result
+): (...args: Args) => Result {
+ const callbackRef = useRef<(...args: Args) => Result>(callback);
+ callbackRef.current = callback;
+ return useFactoryRef(() => (...args: Args) => callbackRef.current(...args));
+}
diff --git a/src/hooks/useLiveInput.ts b/src/hooks/useLiveInput.ts
new file mode 100644
index 0000000..1836b5d
--- /dev/null
+++ b/src/hooks/useLiveInput.ts
@@ -0,0 +1,67 @@
+import { useCallback, useState } from "react";
+import { useTimeoutRef } from "./useTimeoutRef";
+import { useLatestCallbackRef } from "./useLatestCallbackRef";
+
+/**
+ * Дебаунсит событие изменения, сохраняя в стейте флаг активности дебауса.
+ * Например, при использовании с формой поиска, когда пользователь нажимает
+ * какую-либо клавишу, стейт isNewInput становится true, но колбек onSubmit
+ * не вызывается. В этот момент можно показать пользователю индикатор загрузки,
+ * сделав вид, что запрос отправился, хотя на самом деле запрос отправится
+ * только после того, как пользователь не будет ничего нажимать в течение
+ * времени delay.
+ *
+ * @param onSubmit - колбек, который вызывается с дебаунсом
+ * @param delay - время дебаунса
+ * @example
+ * // Какой-то хук загрузки данных
+ * const [data, isLoading, loadData] = useData();
+ *
+ * const [search, setSearch] = useState('');
+ * const [isNewInput, submit] = useLiveInput((value) => {
+ * loadData(value);
+ * });
+ * const handleChange = (e) => {
+ * setSearch(search);
+ * // При вводе в инпут инициируем запрос с дебаунсом
+ * submit(e.target.value, true);
+ * };
+ * const handleSubmit = () => {
+ * // При отправке формы отправляем запрос сразу
+ * submit(search, false);
+ * };
+ *
+ * return
+ *
+ *
+ * {(isNewInput || isLoading) ? : }
+ *
+ *
;
+ */
+export function useLiveInput(onSubmit: (value: Value) => void, delay: number = 500) {
+ const [isNewInput, setIsNewInput] = useState(false);
+ const submitTimeoutRef = useTimeoutRef();
+ const setFiltersRef = useLatestCallbackRef((value: Value) => {
+ setIsNewInput(false);
+ onSubmit(value);
+ });
+ const handleSubmit = useCallback(
+ (value: Value, useTimeout: boolean = false) => {
+ if (useTimeout) {
+ setIsNewInput(true);
+ submitTimeoutRef.set(() => {
+ setFiltersRef(value);
+ }, delay);
+ } else {
+ submitTimeoutRef.clear();
+ setFiltersRef(value);
+ }
+ },
+ [submitTimeoutRef, setFiltersRef, delay],
+ );
+
+ return [isNewInput, handleSubmit] as const;
+}
diff --git a/src/hooks/useMediaQueryResult.ts b/src/hooks/useMediaQueryResult.ts
new file mode 100644
index 0000000..8030b78
--- /dev/null
+++ b/src/hooks/useMediaQueryResult.ts
@@ -0,0 +1,39 @@
+import { useMemo, useState, useEffect } from 'react';
+
+export const breakpointMobileSmall = 320;
+export const breakpointMobileLarge = 375;
+export const breakpointTabletSmall = 768;
+export const breakpointTabletLarge = 1024;
+export const breakpointDesktopSmall = 1280;
+export const breakpointDesktopMedium = 1440;
+export const breakpointDesktopLarge = 1920;
+
+export const mediaQuery = {
+ mobileLargeDown: `(max-width: ${breakpointMobileLarge - 1}px)`, // 374
+ tabletSmallDown: `(max-width: ${breakpointTabletSmall - 1}px)`, // 767
+ tabletSmallUp: `(min-width: ${breakpointTabletSmall}px)`, // 767
+ tabletLargeDown: `(max-width: ${breakpointTabletLarge - 1}px)`, // 1023
+ tabletLargeUp: `(min-width: ${breakpointTabletLarge}px)`, // 1024
+ desktopSmallDown: `(max-width: ${breakpointDesktopSmall - 1}px)`, // 1279
+ desktopSmallUp: `(min-width: ${breakpointDesktopSmall}px)`, // 1280
+ desktopMediumDown: `(max-width: ${breakpointDesktopMedium - 1}px)`, // 1439
+ desktopMediumUp: `(min-width: ${breakpointDesktopMedium}px)`, // 1440
+ desktopLarge: `(min-width: ${breakpointDesktopLarge}px)` // 1919
+};
+
+export function useMediaQueryResult(mediaQueryString: string) {
+ const mediaQueryList = useMemo(() => {
+ return window.matchMedia(mediaQueryString);
+ }, [mediaQueryString]);
+
+ const [queryResult, setQueryResult] = useState(mediaQueryList.matches);
+
+ useEffect(() => {
+ const handleMediaQueryListChange = (e: MediaQueryListEvent) => setQueryResult(e.matches);
+
+ mediaQueryList.addEventListener('change', handleMediaQueryListChange);
+ return () => mediaQueryList.removeEventListener('change', handleMediaQueryListChange);
+ }, [mediaQueryList]);
+
+ return queryResult;
+}
diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts
new file mode 100644
index 0000000..44e7f4c
--- /dev/null
+++ b/src/hooks/usePrevious.ts
@@ -0,0 +1,15 @@
+import { useEffect, useRef } from 'react';
+
+export const usePrevious = (value: T, overwriteWhenUndefined = true): T | undefined => {
+ const ref = useRef();
+
+ useEffect(() => {
+ if (!overwriteWhenUndefined && value === undefined) {
+ return;
+ }
+
+ ref.current = value;
+ }, [value, overwriteWhenUndefined]);
+
+ return ref.current;
+};
diff --git a/src/hooks/usePropsRef.ts b/src/hooks/usePropsRef.ts
new file mode 100644
index 0000000..7d5fc48
--- /dev/null
+++ b/src/hooks/usePropsRef.ts
@@ -0,0 +1,7 @@
+import { useRef } from 'react';
+
+export function usePropsRef(props: T) {
+ const ref = useRef(props);
+ Object.assign(ref.current, props);
+ return ref.current;
+}
diff --git a/src/hooks/useSingleTimeout.ts b/src/hooks/useSingleTimeout.ts
new file mode 100644
index 0000000..6752bb5
--- /dev/null
+++ b/src/hooks/useSingleTimeout.ts
@@ -0,0 +1,6 @@
+import { SingleTimeoutManager } from '../utils/SingleTimeoutManager';
+import { useFactoryRef } from './useFactoryRef';
+
+export const useSingleTimeout = () => {
+ return useFactoryRef(() => new SingleTimeoutManager());
+};
diff --git a/src/hooks/useTimeoutRef.ts b/src/hooks/useTimeoutRef.ts
new file mode 100644
index 0000000..6a34be5
--- /dev/null
+++ b/src/hooks/useTimeoutRef.ts
@@ -0,0 +1,39 @@
+import { useEffect, useRef } from 'react';
+import {SingleTimeoutManager} from '../utils/SingleTimeoutManager';
+
+/**
+ * Создает таймаут менеджер, который автоматически очищает активный
+ * таймаут при анмаунте компонента. Активен может быть только один
+ * таймаут. При создании нового предыдущий отключается.
+ *
+ * @example
+ * function MyComponent() {
+ * const [loading, setLoading] = useState(false);
+ * const timeoutRef = useTimeoutRef();
+ * const handleClick = () => {
+ * setLoading(true);
+ * timeoutRef.set(() => {
+ * setLoading(false);
+ * }, 2000);
+ * };
+ *
+ * return ;
+ * }
+ */
+export function useTimeoutRef() {
+ const ref = useRef(null);
+
+ if (!ref.current) {
+ ref.current = new SingleTimeoutManager();
+ }
+
+ useEffect(() => {
+ return () => {
+ if (ref.current) ref.current.clear();
+ };
+ }, []);
+
+ return ref.current;
+}
diff --git a/src/hooks/useToggle.ts b/src/hooks/useToggle.ts
new file mode 100644
index 0000000..4c0844e
--- /dev/null
+++ b/src/hooks/useToggle.ts
@@ -0,0 +1,37 @@
+import { useCallback, useState } from 'react';
+
+type TUseToggleReturn = [
+ value: boolean,
+ funcs: {
+ set: () => void;
+ unset: () => void;
+ toggle: () => void;
+ change: (value: boolean) => void;
+ }
+];
+
+export const useToggle = (defaultValue: boolean = false): TUseToggleReturn => {
+ const [value, change] = useState(defaultValue);
+
+ const set = useCallback(() => {
+ change(true);
+ }, [change]);
+
+ const unset = useCallback(() => {
+ change(false);
+ }, [change]);
+
+ const toggle = useCallback(() => {
+ change((v) => !v);
+ }, [change]);
+
+ return [
+ value,
+ {
+ change,
+ set,
+ unset,
+ toggle
+ }
+ ];
+};
diff --git a/src/hooks/useUrlParam.ts b/src/hooks/useUrlParam.ts
new file mode 100644
index 0000000..e583b21
--- /dev/null
+++ b/src/hooks/useUrlParam.ts
@@ -0,0 +1,24 @@
+import { useMemo } from 'react';
+import { useParams } from 'react-router-dom';
+
+export interface UseUrlParamOptions {
+ parser?: (value: string) => T;
+}
+
+export const useUrlParam = (key: string, options: UseUrlParamOptions = {}) => {
+ const { [key]: param } = useParams();
+
+ const value = useMemo(() => {
+ if (param === undefined) {
+ return null;
+ }
+
+ if (options.parser) {
+ return options.parser(param);
+ }
+
+ return param;
+ }, [param, options]);
+
+ return value as T | null;
+};
diff --git a/src/index.css b/src/index.css
deleted file mode 100644
index ec2585e..0000000
--- a/src/index.css
+++ /dev/null
@@ -1,13 +0,0 @@
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
-}
diff --git a/src/index.tsx b/src/index.tsx
index 032464f..1bad816 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,16 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
-import './index.css';
-import App from './App';
import reportWebVitals from './reportWebVitals';
+import {App} from './app';
+import {AppProvider} from './app';
+
+import 'focus-visible';
+
+import 'sanitize.css';
+import 'sanitize.css/forms.css';
+import 'sanitize.css/typography.css';
+import './app/styles/index.scss';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
-
-
-
+ //
+
+
+
+ //
);
// If you want to start measuring performance in your app, pass a function
diff --git a/src/lib/axios.ts b/src/lib/axios.ts
new file mode 100644
index 0000000..d86eb8d
--- /dev/null
+++ b/src/lib/axios.ts
@@ -0,0 +1,10 @@
+import Axios from 'axios';
+import {API_URL} from '../config';
+
+export const axios = Axios.create({
+ baseURL: API_URL
+});
+
+axios.interceptors.response.use((response) => {
+ return response.data;
+})
\ No newline at end of file
diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts
new file mode 100644
index 0000000..bc14630
--- /dev/null
+++ b/src/lib/react-query.ts
@@ -0,0 +1,38 @@
+import {
+ QueryClient,
+ UseQueryOptions,
+ UseMutationOptions,
+ DefaultOptions,
+ UseInfiniteQueryOptions
+} from '@tanstack/react-query';
+import { AxiosError } from 'axios';
+import { ExtractFnReturnType } from '../utils/types';
+
+const queryConfig: DefaultOptions = {
+ queries: {
+ useErrorBoundary: false,
+ refetchOnWindowFocus: false,
+ retry: false
+ }
+};
+
+export const queryClient = new QueryClient({ defaultOptions: queryConfig });
+
+export type QueryConfig any> = Omit<
+ UseQueryOptions>,
+ 'queryKey' | 'queryFn'
+>;
+
+export type InfiniteQueryConfig any> = Omit<
+ UseInfiniteQueryOptions>,
+ 'queryKey' | 'queryFn'
+>;
+
+export type MutationConfig any> = UseMutationOptions<
+ ExtractFnReturnType,
+ AxiosError,
+ Parameters[0],
+ any
+>;
+
+export type { ExtractFnReturnType };
diff --git a/src/logo.svg b/src/logo.svg
deleted file mode 100644
index 9dfc1c0..0000000
--- a/src/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/pages/_layouts/BaseLayout/BaseLayout.tsx b/src/pages/_layouts/BaseLayout/BaseLayout.tsx
new file mode 100644
index 0000000..8cec2e9
--- /dev/null
+++ b/src/pages/_layouts/BaseLayout/BaseLayout.tsx
@@ -0,0 +1 @@
+export {};
\ No newline at end of file
diff --git a/src/pages/_layouts/BaseLayout/index.ts b/src/pages/_layouts/BaseLayout/index.ts
new file mode 100644
index 0000000..8cec2e9
--- /dev/null
+++ b/src/pages/_layouts/BaseLayout/index.ts
@@ -0,0 +1 @@
+export {};
\ No newline at end of file
diff --git a/src/pages/chat/ChatPage.module.scss b/src/pages/chat/ChatPage.module.scss
new file mode 100644
index 0000000..58ce3b1
--- /dev/null
+++ b/src/pages/chat/ChatPage.module.scss
@@ -0,0 +1,86 @@
+@import 'src/app/styles/vars';
+
+// Общий лейаут
+
+.ChatPage {
+
+}
+
+.ChatPage__inner {
+ position: relative;
+ //width: $breakpoint-tablet-small;
+ width: 100%;
+ height: 100vh;
+ max-height: 100svh;
+ margin: 0 auto;
+ overflow: hidden;
+ background-image: url('./assets/background2.jpg');
+ background-size: 150%;
+
+ @include media-down(tablet-small) {
+ width: 100%;
+ }
+}
+
+.ChatPage__container {
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ z-index: 1;
+ //background-color: rgba($color-on-surface-dark-100, 0.6);
+ //background-color: $color-surface-primary;
+ background-color: rgba($color-surface-primary, 0.7);
+ backdrop-filter: blur(10px);
+ padding: $spacing-medium-x $spacing-small 0;
+
+ overflow-y: scroll;
+
+ @include scrollbar;
+ @include flex-col;
+}
+
+// Инпут
+
+.ChatPage__inputContainer {
+ position: absolute;
+ width: 100%;
+ min-height: 100px;
+ padding: $spacing-small;
+ bottom: 0;
+ background-color: rgba($color-surface-primary, 0.6);
+ backdrop-filter: blur(10px);
+ z-index: 2;
+ @include flex-col;
+}
+
+.ChatPage__skipButton {
+ width: 100% !important;
+ margin-top: $spacing-small;
+}
+
+// Секция с контентом и сообщениями
+
+.ChatPage__content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ //justify-content: flex-end;
+ row-gap: $spacing-small;
+}
+
+.ChatPage__message {
+ width: fit-content;
+ max-width: 66%;
+
+ @include media-down(tablet-small) {
+ max-width: 100%;
+ }
+}
+
+.ChatPage__message_right {
+ margin-left: auto;
+}
+
+.ChatPage__message_left {
+ margin-right: auto;
+}
\ No newline at end of file
diff --git a/src/pages/chat/ChatPage.tsx b/src/pages/chat/ChatPage.tsx
new file mode 100644
index 0000000..c73de6c
--- /dev/null
+++ b/src/pages/chat/ChatPage.tsx
@@ -0,0 +1,188 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+import clsx from 'clsx';
+import s from './ChatPage.module.scss';
+import {ReactFCC} from '../../utils/ReactFCC';
+import {ChatContent} from './components/ChatContent';
+import {ChatItemType, useChatHistory} from './store/history';
+import {SubmitHandler} from 'react-hook-form';
+import {getFirstQuestion, useCreateDeck} from '../../api/deck';
+import {ChatFormInitial} from './components/ChatForm/ChatFormInittial';
+import {useChatUi} from './hooks/useChatUi';
+import {useQuestion} from '../../api/deck/getQuestion';
+import {useCreateAnswer} from '../../api/deck/createAnswer';
+import {QuestionFactory} from './components/ChatForm/QuestionFactory';
+import {useSingleTimeout} from '../../hooks/useSingleTimeout';
+import {usePrevious} from '../../hooks/usePrevious';
+import {generateAnswerFromData} from './utils/generateAnswerFromData';
+import {generateTextFromAnswer} from './utils/generateTextFromAnswer';
+import {Button, ButtonVariant} from '../../components/Button';
+
+export interface ChatPageProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+}
+
+const QUESTION_POLLING_MS = 1000;
+
+const DEFAULT_DECK_ID = 0;
+const DEFAULT_QUESTION_ID = 0;
+
+export const ChatPage: ReactFCC = (props) => {
+ const {className} = props;
+
+ const timeout = useSingleTimeout();
+
+ // Работа с UI
+ const { backgroundRef, containerRef, contentRef, inputContainerRef } = useChatUi();
+
+ const { history, pushHistory } = useChatHistory();
+ const initRef = useRef(false);
+
+ // Устанавливаем первый вопрос в чат
+ useEffect(() => {
+ if (!initRef.current) {
+ pushHistory({
+ type: ChatItemType.receive,
+ text: 'Введите описание проекта',
+ });
+ initRef.current = true;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /**
+ * Работа с API
+ */
+
+ const [deckId, setDeckId] = useState(DEFAULT_DECK_ID);
+ const [questionId, setQuestionId] = useState(DEFAULT_QUESTION_ID);
+
+ const { mutateAsync: createDeck } = useCreateDeck();
+
+ const onSubmitInitial: SubmitHandler = useCallback(async (data) => {
+ const deck = await createDeck({
+ description: data.description
+ });
+ setDeckId(deck.id);
+ pushHistory({
+ type: ChatItemType.send,
+ text: deck.description as string
+ });
+
+ const firstQuestion = await getFirstQuestion({ deckId });
+ setQuestionId(firstQuestion.id);
+ }, [createDeck, deckId, pushHistory]);
+
+ // Начинаем пинг-понг вопросов-ответов
+
+ const { data: question, refetch: refetchQuestion } = useQuestion({
+ deckId,
+ questionId,
+ config: {
+ enabled: !!(deckId && questionId),
+ // keepPreviousData: true,
+ }
+ });
+
+ const prevQuestion = usePrevious(question);
+
+ useEffect(() => {
+ if (question && question.id !== prevQuestion?.id) {
+ timeout.clear();
+
+ pushHistory({
+ type: ChatItemType.receive,
+ text: question.text
+ });
+
+ const startPolling = () => {
+ timeout.set(async () => {
+ const { data: newQuestion } = await refetchQuestion();
+ if (newQuestion?.hint && !newQuestion.hint.type) {
+ startPolling();
+ }
+ }, QUESTION_POLLING_MS);
+ }
+
+ if (question?.hint && !question.hint.type) {
+ startPolling();
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pushHistory, question]);
+
+
+ const { mutateAsync: createAnswer } = useCreateAnswer();
+
+ const onSubmit: SubmitHandler = useCallback(async (data) => {
+ if (!question || !data.value) {
+ return;
+ }
+
+ timeout.clear();
+
+ const answerValue = generateAnswerFromData(question, data);
+
+ const answer = await createAnswer({
+ deckId,
+ questionId,
+ answer: answerValue
+ });
+
+ pushHistory({
+ type: ChatItemType.send,
+ text: generateTextFromAnswer(question.type, answer)
+ });
+
+ setQuestionId(question!.next_id);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [createAnswer, deckId, pushHistory, question, questionId]);
+
+ // Пропуск вопроса
+ const onSkip = useCallback(() => {
+ if (question && !question.params?.required) {
+ setQuestionId(question.next_id);
+ }
+ }, [question]);
+
+ // ---------- Скролл чата ----------
+ // todo при печатании текста тоже двигать скролл
+ useEffect(() => {
+ if (contentRef.current && inputContainerRef.current) {
+ contentRef.current.style.paddingBottom = inputContainerRef.current.scrollHeight + 'px';
+ containerRef.current?.scrollTo({ top: contentRef.current.scrollHeight });
+ }
+ }, [containerRef, contentRef, history, question, inputContainerRef]);
+
+ return (
+
+
+
+
+
+ {question ? (
+ <>
+
+ {!question.params?.required && (
+
+ )}
+ >
+ ) : !deckId ? (
+
+ ) : null}
+
+
+
+ );
+};
+
diff --git a/src/pages/chat/assets/background.jpg b/src/pages/chat/assets/background.jpg
new file mode 100644
index 0000000..aa9a963
Binary files /dev/null and b/src/pages/chat/assets/background.jpg differ
diff --git a/src/pages/chat/assets/background2.jpg b/src/pages/chat/assets/background2.jpg
new file mode 100644
index 0000000..1e8884e
Binary files /dev/null and b/src/pages/chat/assets/background2.jpg differ
diff --git a/src/pages/chat/components/ChatContent.tsx b/src/pages/chat/components/ChatContent.tsx
new file mode 100644
index 0000000..9d32631
--- /dev/null
+++ b/src/pages/chat/components/ChatContent.tsx
@@ -0,0 +1,86 @@
+import clsx from 'clsx';
+import {ReactFCC} from '../../../utils/ReactFCC';
+import s from '../ChatPage.module.scss';
+import {Message, MessageType, MessageVariant} from '../../../components/Message';
+import {Fragment, memo} from 'react';
+import {mediaQuery, useMediaQueryResult} from '../../../hooks/useMediaQueryResult';
+import {ChatItem, ChatItemType} from '../store/history';
+
+export interface ChatMockProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ history: ChatItem[];
+}
+
+export const ChatContent: ReactFCC = memo(function ChatMock(props) {
+ const { history } = props;
+
+ const isLarge = useMediaQueryResult(mediaQuery.desktopMediumUp);
+ const messageTypeForSend = isLarge ? MessageType.left : MessageType.right;
+ const messageTypeClassForSend = isLarge ? s.ChatPage__message_left : s.ChatPage__message_right;
+
+ return (
+ <>
+ {history.map((item, index) => (
+
+ {item.text}
+
+ ))}
+ {/*{Array(5).fill(null).map((_, index) => (*/}
+ {/* */}
+ {/* */}
+ {/* Какие метрики вы используете чтобы отслеживать прогресс развития проекта?*/}
+ {/* */}
+
+ {/* */}
+ {/* Возможными метриками для отслеживания прогресса могут быть: количество скачиваний и использования приложения/сервиса, количество активных пользователей, уровень удовлетворенности пользователей, объем продаж/дохода, показатели роста/расширения компании и др.*/}
+ {/* */}
+
+ {/* */}
+ {/* На чем вы зарабатываете? Сколько и за что вам платят клиенты*/}
+ {/* */}
+
+ {/* */}
+ {/* Проект может зарабатывать на платной подписке*/}
+ {/* */}
+
+ {/* */}
+ {/* Какие метрики вы используете чтобы отслеживать прогресс развития проекта?*/}
+ {/* */}
+
+ {/* */}
+ {/* Возможными метриками для отслеживания прогресса могут быть: количество скачиваний и использования приложения/сервиса, количество активных пользователей, уровень удовлетворенности пользователей, объем продаж/дохода, показатели роста/расширения компании и др.*/}
+ {/* */}
+ {/* */}
+ {/*))}*/}
+ >
+ );
+});
+
diff --git a/src/pages/chat/components/ChatForm/ChatFormInittial.tsx b/src/pages/chat/components/ChatForm/ChatFormInittial.tsx
new file mode 100644
index 0000000..b9db4d3
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/ChatFormInittial.tsx
@@ -0,0 +1,54 @@
+import {ReactFCC} from '../../../../utils/ReactFCC';
+import s from './components/ChatFormText/ChatFormText.module.scss';
+import {Textarea} from '../../../../components/Textarea';
+import {KeyboardEvent} from 'react';
+import {isKey} from '../../../../utils/isKey';
+import {Key} from 'ts-key-enum';
+import {Button} from '../../../../components/Button';
+import {Form} from '../../../../components/Form';
+import {SubmitHandler} from 'react-hook-form';
+import {ReactComponent as RightIcon} from '../../../../assets/icons/right.svg';
+
+export interface ChatFormInitialProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ onSubmit: SubmitHandler;
+}
+
+export const ChatFormInitial: ReactFCC = (props) => {
+ const {onSubmit} = props;
+
+ return (
+
+ );
+};
+
diff --git a/src/pages/chat/components/ChatForm/QuestionFactory.tsx b/src/pages/chat/components/ChatForm/QuestionFactory.tsx
new file mode 100644
index 0000000..c0490b8
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/QuestionFactory.tsx
@@ -0,0 +1,190 @@
+import {ReactFCC} from '../../../../utils/ReactFCC';
+import {EntityType, Hint, Question} from '../../../../api/deck';
+import {Form} from '../../../../components/Form';
+import {ChatFormText} from './components/ChatFormText/ChatFormText';
+import {ChatFormSelect} from './components/ChatFormSelect';
+import {ChatFormMultipleRange, RangeType} from './components/ChatFormMultipleRange';
+import {ChatFormMultipleDateDescription} from './components/ChatFormMultipleDateDescription';
+import {ChatFormRange} from './components/ChatFormRange';
+import {ChatFormMultipleLinkDescription} from './components/ChatFormMultipleLinkDescription';
+
+export interface QuestionFactoryProps {
+ type: EntityType;
+ params: Question['params'];
+ onSubmit: (data: any) => void;
+ hint?: Question['hint'];
+}
+
+export const QuestionFactory: ReactFCC = (props) => {
+ const {type, params, onSubmit, hint} = props;
+
+ switch (type) {
+ case EntityType.text:
+ return (
+
+ )
+ case EntityType.number:
+ return (
+
+ )
+ case EntityType.date:
+ return (
+
+ )
+ case EntityType.link:
+ return (
+
+ )
+ case EntityType.select:
+ return (
+
+ );
+ case EntityType.multiple_range:
+ return (
+
+ )
+ case EntityType.multiple_date_description:
+ return (
+
+ )
+ case EntityType.range:
+ return (
+
+ )
+ case EntityType.multiple_link_description:
+ return (
+
+ )
+ default:
+ return null;
+ }
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.module.scss b/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.module.scss
new file mode 100644
index 0000000..124e380
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.module.scss
@@ -0,0 +1,34 @@
+@import 'src/app/styles/vars';
+
+.ChatFormMultipleDateDescription {
+ @include flex-col;
+ gap: $spacing-small;
+}
+
+.ChatFormMultipleDateDescription__items {
+ @include flex-col;
+ gap: $spacing-small;
+}
+
+.ChatFormMultipleDateDescription__group {
+ @include flex-col;
+ align-items: flex-end;
+ gap: $spacing-small-3x;
+}
+
+.ChatFormMultipleDateDescription__input {
+ width: 100%;
+}
+
+.ChatFormMultipleDateDescription__textarea {
+ width: 100%;
+}
+
+.ChatFormMultipleDateDescription__buttons {
+ @include flex;
+ gap: $spacing-small-3x;
+}
+
+.ChatFormMultipleDateDescription__hint {
+ white-space: pre-wrap;
+}
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.tsx b/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.tsx
new file mode 100644
index 0000000..389fbd0
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.tsx
@@ -0,0 +1,85 @@
+import clsx from 'clsx';
+import {ReactFCC} from '../../../../../../utils/ReactFCC';
+import {Control, Controller, FieldValues, UseFormRegisterReturn} from 'react-hook-form';
+import {SimpleButton} from '../../../SimpleButton';
+import s from './ChatFormMultipleDateDescription.module.scss';
+import {Hint} from '../../../../../../api/deck';
+import {Input} from '../../../../../../components/Input';
+import {Textarea} from '../../../../../../components/Textarea';
+import {Button, ButtonVariant} from '../../../../../../components/Button';
+import {Hint as HintCmp, HintsContainer} from '../../../../../../components/Hint';
+import {formatDate} from '../../../../../../utils/fomat';
+import format from 'date-fns/format';
+
+export interface ChatFormMultipleDateDescriptionProps {
+ className?: string;
+ registration: Partial;
+ control: Control;
+ onSubmit: (e: any) => void;
+ setValue: (value: any) => void;
+ hint?: Hint | false;
+}
+
+export const ChatFormMultipleDateDescription: ReactFCC = (props) => {
+ const {className, registration, control, onSubmit, hint, setValue} = props;
+
+ return (
+
+
(
+ <>
+
+ {hint && hint.value && (
+ setValue({ ...hint.value })}
+ >
+ {Object.entries(hint.value).map(([key, val]) => `${formatDate(key)}: ${val}`).join('\n')}
+
+ )}
+
+
+
+ {Object.entries(value).map(([date, text]: any, index, { length: arrLength }) => {
+ return (
+
+
{
+ const newValue = { ...value };
+ const text = newValue[date];
+ delete newValue[date];
+ onChange({ ...newValue, [new Date(e.target.value).toISOString()]: text })
+ }}
+ />
+
+ )
+ })}
+
+
+
+ >
+ )} name={registration.name!} />
+
+ )
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/index.ts b/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/index.ts
new file mode 100644
index 0000000..32938ec
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleDateDescription/index.ts
@@ -0,0 +1 @@
+export * from './ChatFormMultipleDateDescription';
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleLinkDescription/ChatFormMultipleLinkDescription.tsx b/src/pages/chat/components/ChatForm/components/ChatFormMultipleLinkDescription/ChatFormMultipleLinkDescription.tsx
new file mode 100644
index 0000000..485c847
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleLinkDescription/ChatFormMultipleLinkDescription.tsx
@@ -0,0 +1,92 @@
+import clsx from 'clsx';
+import {ReactFCC} from '../../../../../../utils/ReactFCC';
+import {Control, Controller, FieldValues, UseFormRegisterReturn} from 'react-hook-form';
+import {SimpleButton} from '../../../SimpleButton';
+import s from '../ChatFormMultipleDateDescription/ChatFormMultipleDateDescription.module.scss';
+import {Hint} from '../../../../../../api/deck';
+import {Input} from '../../../../../../components/Input';
+import {Textarea} from '../../../../../../components/Textarea';
+import {Button, ButtonVariant} from '../../../../../../components/Button';
+import {Hint as HintCmp, HintsContainer} from '../../../../../../components/Hint';
+
+export interface ChatFormMultipleLinkDescriptionProps {
+ className?: string;
+ registration: Partial;
+ control: Control;
+ onSubmit: (e: any) => void;
+ setValue: (value: any) => void;
+ hint?: Hint | false;
+}
+
+export const ChatFormMultipleLinkDescription: ReactFCC = (props) => {
+ const {className, registration, control, onSubmit, hint, setValue} = props;
+
+ return (
+
+
(
+ <>
+ {/**/}
+ {/* {hint && hint.value && (*/}
+ {/* setValue({ ...hint.value })}*/}
+ {/* >*/}
+ {/* {Object.entries(hint.value).map(([key, val]) => `${key}: ${val}`).join('\n')}*/}
+ {/* */}
+ {/* )}*/}
+ {/**/}
+
+
+ {Object.entries(value).map(([link, text]: any, index, { length: arrLength }) => {
+ return (
+
+
{
+ const newValue = { ...value };
+ const text = newValue[link];
+ delete newValue[link];
+ console.log()
+ onChange({ ...newValue, [e.target.value]: text })
+ }}
+ />
+
+ )
+ })}
+
+
+
+ >
+ )} name={registration.name!} />
+
+ )
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleLinkDescription/index.ts b/src/pages/chat/components/ChatForm/components/ChatFormMultipleLinkDescription/index.ts
new file mode 100644
index 0000000..48dd75b
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleLinkDescription/index.ts
@@ -0,0 +1 @@
+export * from './ChatFormMultipleLinkDescription';
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/ChatFormMultipleRange.module.scss b/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/ChatFormMultipleRange.module.scss
new file mode 100644
index 0000000..203b61d
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/ChatFormMultipleRange.module.scss
@@ -0,0 +1,20 @@
+@import 'src/app/styles/vars';
+
+.ChatFormMultipleRange {
+ @include flex-col;
+ max-width: unset;
+ align-items: stretch;
+ width: 100%;
+ margin: 0 auto;
+ gap: $spacing-small;
+}
+
+.ChatFormMultipleRange__items {
+ @include flex-col;
+ gap: $spacing-small;
+ flex: 1;
+}
+
+.ChatFormMultipleRange__button {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/ChatFormMultipleRange.tsx b/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/ChatFormMultipleRange.tsx
new file mode 100644
index 0000000..0e062b1
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/ChatFormMultipleRange.tsx
@@ -0,0 +1,62 @@
+import clsx from 'clsx';
+import {ReactFCC} from '../../../../../../utils/ReactFCC';
+import {Control, Controller, FieldValues, UseFormRegisterReturn} from 'react-hook-form';
+import {SimpleButton} from '../../../SimpleButton';
+import s from './ChatFormMultipleRange.module.scss';
+import {Range} from '../../../../../../components/Range';
+import {Hint} from '../../../../../../api/deck';
+import {Hint as HintCmp, HintsContainer} from '../../../../../../components/Hint';
+
+export interface RangeType {
+ name: string;
+ slug: string;
+ min_value: number;
+ max_value: number;
+}
+
+export interface ChatFormSelectProps {
+ className?: string;
+ registration: Partial;
+ control: Control;
+ scrollbars: RangeType[];
+ onSubmit: (e: any) => void;
+ setValue: (value: any) => void;
+ hint?: Hint | false;
+}
+
+export const slugsForFormat = ['sam', 'som', 'tam', 'sum'];
+
+export const ChatFormMultipleRange: ReactFCC = (props) => {
+ const {className, registration, control, scrollbars, onSubmit, hint, setValue} = props;
+
+ return (
+
+
(
+ <>
+
+ {hint && hint.value && Object.entries(hint.value).map(([key, val], index) => {
+ const range = scrollbars.find((j) => j.slug === key);
+ return range ? (
+ setValue({ ...value, [key]: val })} key={index}>{`${range.name}: ${val}`}
+ ) : null
+ })}
+
+
+
+ {scrollbars.map((item, index) => (
+ onChange({ ...value, [item.slug]: val })}
+ format={slugsForFormat.includes(item.slug)}
+ key={index} />
+ ))}
+
+
+
+ >
+ )} name={registration.name!} />
+
+ )
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/index.ts b/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/index.ts
new file mode 100644
index 0000000..7d54a61
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormMultipleRange/index.ts
@@ -0,0 +1 @@
+export * from './ChatFormMultipleRange';
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormRange/ChatFormRange.tsx b/src/pages/chat/components/ChatForm/components/ChatFormRange/ChatFormRange.tsx
new file mode 100644
index 0000000..916dfc2
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormRange/ChatFormRange.tsx
@@ -0,0 +1,52 @@
+import clsx from 'clsx';
+import {ReactFCC} from '../../../../../../utils/ReactFCC';
+import {Control, Controller, FieldValues, UseFormRegisterReturn} from 'react-hook-form';
+import {SimpleButton} from '../../../SimpleButton';
+import s from './../ChatFormMultipleRange/ChatFormMultipleRange.module.scss';
+import {Range} from '../../../../../../components/Range';
+import {Hint} from '../../../../../../api/deck';
+import {Hint as HintCmp, HintsContainer} from '../../../../../../components/Hint';
+import {RangeType, slugsForFormat} from '../ChatFormMultipleRange';
+import {currencyFormatter} from '../../../../../../utils/fomat';
+
+export interface ChatFormRangeProps {
+ className?: string;
+ registration: Partial;
+ control: Control;
+ onSubmit: (e: any) => void;
+ setValue: (value: any) => void;
+ hint?: Hint | false;
+ range: RangeType;
+}
+
+export const ChatFormRange: ReactFCC = (props) => {
+ const {className, registration, control, onSubmit, hint, setValue, range} = props;
+
+ return (
+
+
(
+ <>
+
+ {hint && hint.value && (
+ setValue(hint.value)}>
+ {slugsForFormat.includes(range.slug) ? currencyFormatter.format(hint.value) : hint.value}
+
+ )}
+
+
+
+ onChange(val)}
+ format={slugsForFormat.includes(range.slug)}
+ />
+
+
+
+ >
+ )} name={registration.name!} />
+
+ )
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormRange/index.ts b/src/pages/chat/components/ChatForm/components/ChatFormRange/index.ts
new file mode 100644
index 0000000..db31d63
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormRange/index.ts
@@ -0,0 +1 @@
+export * from './ChatFormRange';
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormSelect/ChatFormSelect.module.scss b/src/pages/chat/components/ChatForm/components/ChatFormSelect/ChatFormSelect.module.scss
new file mode 100644
index 0000000..73a9c42
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormSelect/ChatFormSelect.module.scss
@@ -0,0 +1,12 @@
+@import 'src/app/styles/vars';
+
+.ChatFormSelect {
+ @include flex-col;
+ align-items: flex-start;
+ gap: $spacing-small;
+}
+
+.ChatFormSelect__items {
+ @include flex-col;
+ gap: $spacing-small-3x;
+}
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormSelect/ChatFormSelect.tsx b/src/pages/chat/components/ChatForm/components/ChatFormSelect/ChatFormSelect.tsx
new file mode 100644
index 0000000..72fdfa4
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormSelect/ChatFormSelect.tsx
@@ -0,0 +1,38 @@
+import clsx from 'clsx';
+import s from './ChatFormSelect.module.scss';
+import {ReactFCC} from '../../../../../../utils/ReactFCC';
+import {Control, Controller, FieldValues, UseFormRegisterReturn} from 'react-hook-form';
+import {Radio} from '../../../../../../components/Radio';
+import {SimpleButton} from '../../../SimpleButton';
+
+export interface ChatFormSelectProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ registration: Partial;
+ control: Control;
+ options: string[];
+ onSubmit: (e: any) => void;
+}
+
+export const ChatFormSelect: ReactFCC = (props) => {
+ const {className, registration, control, options, onSubmit} = props;
+
+ return (
+
+
(
+ <>
+
+ {options.map((item, index) => (
+ onChange(val)} key={index} />
+ ))}
+
+
+
+ >
+ )} name={registration.name!} />
+
+ )
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormSelect/index.ts b/src/pages/chat/components/ChatForm/components/ChatFormSelect/index.ts
new file mode 100644
index 0000000..bb05ebc
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormSelect/index.ts
@@ -0,0 +1 @@
+export * from './ChatFormSelect';
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormText/ChatFormText.module.scss b/src/pages/chat/components/ChatForm/components/ChatFormText/ChatFormText.module.scss
new file mode 100644
index 0000000..2d49d2a
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormText/ChatFormText.module.scss
@@ -0,0 +1,42 @@
+@import 'src/app/styles/vars';
+
+$chat-input-height: 50px;
+$chat-input-radius: $radius-large-x;
+
+.ChatFormText {
+ @include flex-col;
+ gap: $spacing-small;
+}
+
+.ChatFormText__richInput {
+ @include flex;
+ position: relative;
+ width: 100%;
+ min-height: $chat-input-height;
+ height: 100%;
+ align-items: stretch;
+
+ &::before {
+ content: '';
+ position: absolute;
+ width: 100%;
+ }
+}
+
+.ChatFormText__input {
+ border-radius: $chat-input-radius 0 0 $chat-input-radius !important;
+ flex: 1;
+ min-height: 50px !important;
+}
+
+.ChatFormText__richInputButton {
+ width: $chat-input-height !important;
+ height: auto !important;
+ border-radius: 0 $chat-input-radius $chat-input-radius 0 !important;
+ padding: 0 !important;
+}
+
+.ChatFormText__buttonIcon {
+ width: 24px;
+ height: 24px;
+}
\ No newline at end of file
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormText/ChatFormText.tsx b/src/pages/chat/components/ChatForm/components/ChatFormText/ChatFormText.tsx
new file mode 100644
index 0000000..ea2e49d
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormText/ChatFormText.tsx
@@ -0,0 +1,74 @@
+import clsx from 'clsx';
+import {ReactFCC} from '../../../../../../utils/ReactFCC';
+import {Textarea, TextAreaProps} from '../../../../../../components/Textarea';
+import {KeyboardEvent} from 'react';
+import {isKey} from '../../../../../../utils/isKey';
+import {Key} from 'ts-key-enum';
+import {Button} from '../../../../../../components/Button';
+import {ReactComponent as RightIcon} from '../../../../../../assets/icons/right.svg';
+import {EntityType, Hint} from '../../../../../../api/deck';
+import s from './ChatFormText.module.scss';
+import {Hint as HintCmp, HintsContainer} from '../../../../../../components/Hint';
+import {Input, InputProps} from '../../../../../../components/Input';
+
+export interface ChatFormTextProps {
+ className?: string;
+ onSubmit: (e: any) => void;
+ registration?: TextAreaProps['registration'];
+ setValue: (value: any) => void;
+ hint?: Hint | false;
+ type: InputProps['type'] | 'textarea';
+}
+
+export const ChatFormText: ReactFCC = (props) => {
+ const {className, onSubmit, registration, hint, setValue, type} = props;
+
+ return (
+
+
+ {hint && hint.type !== EntityType.cards && hint.value && (
+ <>{Array.isArray(hint.value) ? hint.value.map((item: string, index: number) => (
+ setValue(item)} key={index}>{item}
+ )) : (
+ setValue(hint.value)}>{hint.value}
+ )}>
+ )}
+
+
+
+ {type === 'textarea' ? (
+
+
+ );
+};
+
diff --git a/src/pages/chat/components/ChatForm/components/ChatFormText/index.ts b/src/pages/chat/components/ChatForm/components/ChatFormText/index.ts
new file mode 100644
index 0000000..5a21058
--- /dev/null
+++ b/src/pages/chat/components/ChatForm/components/ChatFormText/index.ts
@@ -0,0 +1 @@
+export * from './ChatFormText';
\ No newline at end of file
diff --git a/src/pages/chat/components/SimpleButton/SimpleButton.module.scss b/src/pages/chat/components/SimpleButton/SimpleButton.module.scss
new file mode 100644
index 0000000..3a17796
--- /dev/null
+++ b/src/pages/chat/components/SimpleButton/SimpleButton.module.scss
@@ -0,0 +1,15 @@
+@import 'src/app/styles/vars';
+
+.SimpleButton {
+ width: 100% !important;
+
+ @include media-down(tablet-small) {
+ width: 100% !important;
+ }
+}
+
+.SimpleButton__text {
+ display: flex;
+ align-items: center;
+ column-gap: $spacing-small-3x;
+}
\ No newline at end of file
diff --git a/src/pages/chat/components/SimpleButton/SimpleButton.tsx b/src/pages/chat/components/SimpleButton/SimpleButton.tsx
new file mode 100644
index 0000000..b9e6257
--- /dev/null
+++ b/src/pages/chat/components/SimpleButton/SimpleButton.tsx
@@ -0,0 +1,25 @@
+import clsx from 'clsx';
+import s from './SimpleButton.module.scss';
+import {Button} from '../../../../components/Button';
+import {ReactFCC} from '../../../../utils/ReactFCC';
+import {ReactComponent as RightIcon} from '../../../../assets/icons/right.svg';
+
+export interface SimpleButtonProps {
+ /**
+ * Дополнительный css-класс
+ */
+ className?: string;
+ onClick?: (e: any) => void;
+}
+
+export const SimpleButton: ReactFCC = (props) => {
+ const {children, className, onClick} = props;
+
+ return (
+
+ );
+};
+
diff --git a/src/pages/chat/components/SimpleButton/index.ts b/src/pages/chat/components/SimpleButton/index.ts
new file mode 100644
index 0000000..529d3a3
--- /dev/null
+++ b/src/pages/chat/components/SimpleButton/index.ts
@@ -0,0 +1 @@
+export * from './SimpleButton';
\ No newline at end of file
diff --git a/src/pages/chat/hooks/useChatUi.ts b/src/pages/chat/hooks/useChatUi.ts
new file mode 100644
index 0000000..837c1e6
--- /dev/null
+++ b/src/pages/chat/hooks/useChatUi.ts
@@ -0,0 +1,51 @@
+import {useEffect, useLayoutEffect, useRef, useState} from 'react';
+import {useIsDesktop} from '../../../hooks/useIsDesktop';
+
+export const useChatUi = () => {
+ const backgroundRef = useRef(null);
+ const containerRef = useRef(null);
+ const contentRef = useRef(null);
+ const inputContainerRef = useRef(null);
+
+ const [bgX, setBgX] = useState(50);
+ const [bgY, setBgY] = useState(50);
+
+ const isDesktop = useIsDesktop();
+
+ useLayoutEffect(() => {
+ if (contentRef.current && inputContainerRef.current) {
+ containerRef.current?.scrollTo({ top: contentRef.current.scrollHeight });
+ contentRef.current.style.paddingBottom = inputContainerRef.current.scrollHeight + 'px';
+ }
+ }, []);
+
+ // useEffect(() => {
+ // function handler(e: MouseEvent) {
+ // if (isDesktop) {
+ // const modifierX = 17;
+ // const modifierY = 5;
+ //
+ // setBgX(50 + (-e.clientX / window.innerWidth) * modifierX);
+ // setBgY(50 + (-e.clientY / window.innerHeight) * modifierY);
+ // }
+ // }
+ //
+ // window.addEventListener('mousemove', handler);
+ //
+ // return () => window.removeEventListener('mousemove', handler);
+ // }, [isDesktop]);
+
+ useEffect(() => {
+ if (backgroundRef.current) {
+ backgroundRef.current.style.backgroundPositionX = bgX + '%';
+ backgroundRef.current.style.backgroundPositionY = bgY + '%';
+ }
+ }, [bgX, bgY]);
+
+ return {
+ backgroundRef,
+ containerRef,
+ contentRef,
+ inputContainerRef
+ }
+}
\ No newline at end of file
diff --git a/src/pages/chat/index.ts b/src/pages/chat/index.ts
new file mode 100644
index 0000000..73d0044
--- /dev/null
+++ b/src/pages/chat/index.ts
@@ -0,0 +1 @@
+export * from './ChatPage';
\ No newline at end of file
diff --git a/src/pages/chat/processes/useInitChat.ts b/src/pages/chat/processes/useInitChat.ts
new file mode 100644
index 0000000..84310e5
--- /dev/null
+++ b/src/pages/chat/processes/useInitChat.ts
@@ -0,0 +1,3 @@
+export const useInitChat = () => {
+
+}
\ No newline at end of file
diff --git a/src/pages/chat/store/history.ts b/src/pages/chat/store/history.ts
new file mode 100644
index 0000000..ef4dc48
--- /dev/null
+++ b/src/pages/chat/store/history.ts
@@ -0,0 +1,21 @@
+import {create} from 'zustand';
+
+export enum ChatItemType {
+ send = 'send',
+ receive = 'receive'
+}
+
+export type ChatItem = {
+ type: ChatItemType;
+ text: string;
+}
+
+export interface ChatHistoryStore {
+ history: ChatItem[];
+ pushHistory: (item: ChatItem) => void;
+}
+
+export const useChatHistory = create((set) => ({
+ history: [],
+ pushHistory: (item: ChatItem) => set((state) => ({ history: [...state.history, item] })),
+}));
\ No newline at end of file
diff --git a/src/pages/chat/utils/generateAnswerFromData.ts b/src/pages/chat/utils/generateAnswerFromData.ts
new file mode 100644
index 0000000..68386d6
--- /dev/null
+++ b/src/pages/chat/utils/generateAnswerFromData.ts
@@ -0,0 +1,28 @@
+import {EntityType, Question} from '../../../api/deck';
+
+export const generateAnswerFromData = (question: Question, data: any) => {
+ switch (question.type) {
+ case EntityType.text:
+ return data.value;
+ case EntityType.number:
+ return parseInt(data.value);
+ case EntityType.date:
+ return new Date(data.value).toISOString();
+ case EntityType.link:
+ return data.value;
+ case EntityType.select:
+ return data.value;
+ case EntityType.multiple_range:
+ return data.value;
+ case EntityType.multiple_date_description:
+ return data.value;
+ case EntityType.range:
+ return {
+ [question.params!.slug]: data.value,
+ };
+ case EntityType.multiple_link_description:
+ return data.value;
+ default:
+ return '';
+ }
+}
\ No newline at end of file
diff --git a/src/pages/chat/utils/generateTextFromAnswer.ts b/src/pages/chat/utils/generateTextFromAnswer.ts
new file mode 100644
index 0000000..780d448
--- /dev/null
+++ b/src/pages/chat/utils/generateTextFromAnswer.ts
@@ -0,0 +1,31 @@
+import {Answer, EntityType} from '../../../api/deck';
+import {currencyFormatter, formatDate} from '../../../utils/fomat';
+import {slugsForFormat} from '../components/ChatForm/components/ChatFormMultipleRange';
+
+export const generateTextFromAnswer = (type: EntityType, answer: Answer) => {
+ switch (type) {
+ case EntityType.text:
+ return answer.answer;
+ case EntityType.number:
+ return answer.answer;
+ case EntityType.date:
+ return formatDate(answer.answer);
+ case EntityType.link:
+ return answer.answer;
+ case EntityType.select:
+ return answer.answer;
+ case EntityType.multiple_range:
+ return Object.entries(answer.answer)
+ .map(([key, value]: any) => slugsForFormat.includes(key) ? currencyFormatter.format(value) : value).join('\n');
+ case EntityType.multiple_date_description:
+ return Object.entries(answer.answer).map(([key, value]) => `${formatDate(new Date(key))}: ${value}`).join('\n');
+ case EntityType.range:
+ const [slug, value]: any = Object.entries(answer.answer)[0];
+ return slugsForFormat.includes(slug) ?
+ currencyFormatter.format(value) : value
+ case EntityType.multiple_link_description:
+ return Object.entries(answer.answer).map(([key, value]) => `${key}: ${value}`).join('\n');
+ default:
+ return '';
+ }
+}
\ No newline at end of file
diff --git a/src/utils/ReactFCC.ts b/src/utils/ReactFCC.ts
new file mode 100644
index 0000000..9209069
--- /dev/null
+++ b/src/utils/ReactFCC.ts
@@ -0,0 +1,3 @@
+import React, { PropsWithChildren } from 'react';
+
+export type ReactFCC = React.FC>;
diff --git a/src/utils/SingleTimeoutManager.ts b/src/utils/SingleTimeoutManager.ts
new file mode 100644
index 0000000..03f0259
--- /dev/null
+++ b/src/utils/SingleTimeoutManager.ts
@@ -0,0 +1,26 @@
+export class SingleTimeoutManager {
+ timeout: NodeJS.Timeout | null = null;
+
+ public set(cb: null | (() => void), time?: number) {
+ if (this.timeout) clearTimeout(this.timeout);
+ this.timeout = setTimeout(() => {
+ this.timeout = null;
+ cb && cb();
+ }, time);
+ }
+
+ public setDelay(time: number) {
+ return this.set(null, time);
+ }
+
+ public clear() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+ }
+
+ public active() {
+ return this.timeout;
+ }
+}
diff --git a/src/utils/fomat.ts b/src/utils/fomat.ts
new file mode 100644
index 0000000..3425c6e
--- /dev/null
+++ b/src/utils/fomat.ts
@@ -0,0 +1,5 @@
+import format from 'date-fns/format';
+
+export const currencyFormatter = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' });
+
+export const formatDate = (date: number | string | Date) => format(new Date(date), 'dd.MM.yyyy');
\ No newline at end of file
diff --git a/src/utils/isKey.ts b/src/utils/isKey.ts
new file mode 100644
index 0000000..207eb33
--- /dev/null
+++ b/src/utils/isKey.ts
@@ -0,0 +1,7 @@
+import { Key } from 'ts-key-enum';
+
+export const isKey = (e: KeyboardEvent, key: Key | string | (Key | string)[]) => {
+ const keyArr = Array.isArray(key) ? key : [key];
+
+ return keyArr.some((key) => e.key === key);
+};
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 0000000..de4c21e
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,43 @@
+import React from 'react';
+import { PromiseValue } from 'type-fest';
+
+export type Optional = T | null | undefined;
+
+export type Extends = Omit & Self;
+
+export type IntrinsicPropsWithoutRef = React.PropsWithoutRef<
+ JSX.IntrinsicElements[E]
+>;
+
+export type AnchorPropsWithoutRef = IntrinsicPropsWithoutRef<'a'>;
+export type DivPropsWithoutRef = IntrinsicPropsWithoutRef<'div'>;
+export type PPropsWithoutRef = IntrinsicPropsWithoutRef<'p'>;
+export type FooterPropsWithoutRef = IntrinsicPropsWithoutRef<'footer'>;
+export type ImgPropsWithoutRef = IntrinsicPropsWithoutRef<'img'>;
+export type ButtonPropsWithoutRef = IntrinsicPropsWithoutRef<'button'>;
+export type InputPropsWithoutRef = IntrinsicPropsWithoutRef<'input'>;
+export type SelectPropsWithoutRef = IntrinsicPropsWithoutRef<'select'>;
+export type UListPropsWithoutRef = IntrinsicPropsWithoutRef<'ul'>;
+export type LiPropsWithoutRef = IntrinsicPropsWithoutRef<'li'>;
+export type SvgPropsWithoutRef = IntrinsicPropsWithoutRef<'svg'>;
+
+export type PolyExtends = {
+ /**
+ * Базовый компонент
+ *
+ * @type React.ElementType
+ */
+ component?: ComponentType;
+} & Extends;
+
+export type AnyObject = {
+ [key: number]: any;
+ [key: string]: any;
+};
+
+export type ExtractFnReturnType any> = PromiseValue>;
+
+export type Pagination = {
+ offset?: number;
+ limit?: number;
+};
diff --git a/yarn.lock b/yarn.lock
index da3d639..6b1f836 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1106,7 +1106,7 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
-"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.22.11"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.11.tgz#7a9ba3bbe406ad6f9e8dd4da2ece453eb23a77a4"
integrity sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==
@@ -1654,6 +1654,11 @@
schema-utils "^3.0.0"
source-map "^0.7.3"
+"@remix-run/router@1.8.0":
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.8.0.tgz#e848d2f669f601544df15ce2a313955e4bf0bafc"
+ integrity sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg==
+
"@rollup/plugin-babel@^5.2.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@@ -1696,6 +1701,11 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.3.3.tgz#16ab6c727d8c2020a5b6e4a176a243ecd88d8d69"
integrity sha512-0xd7qez0AQ+MbHatZTlI1gu5vkG8r7MYRUJAHPAHJBmGLs16zpkrpAVLvjQKQOqaXPDUBwOiJzNc00znHSCVBw==
+"@seznam/compose-react-refs@^1.0.6":
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/@seznam/compose-react-refs/-/compose-react-refs-1.0.6.tgz#6ec4e70bdd6e32f8e70b4100f27267cf306bd8df"
+ integrity sha512-izzOXQfeQLonzrIQb8u6LQ8dk+ymz3WXTIXjvOlTXHq6sbzROg3NWU+9TTAOpEoK9Bth24/6F/XrfHJ5yR5n6Q==
+
"@sinclair/typebox@^0.24.1":
version "0.24.51"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
@@ -1833,6 +1843,19 @@
"@svgr/plugin-svgo" "^5.5.0"
loader-utils "^2.0.0"
+"@tanstack/query-core@4.33.0":
+ version "4.33.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.33.0.tgz#7756da9a75a424e521622b1d84eb55b7a2b33715"
+ integrity sha512-qYu73ptvnzRh6se2nyBIDHGBQvPY1XXl3yR769B7B6mIDD7s+EZhdlWHQ67JI6UOTFRaI7wupnTnwJ3gE0Mr/g==
+
+"@tanstack/react-query@^4.33.0":
+ version "4.33.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.33.0.tgz#e927b0343a6ecaa948fee59e9ca98fe561062638"
+ integrity sha512-97nGbmDK0/m0B86BdiXzx3EW9RcDYKpnyL2+WwyuLHEgpfThYAnXFaMMmnTDuAO4bQJXEhflumIEUfKmP7ESGA==
+ dependencies:
+ "@tanstack/query-core" "4.33.0"
+ use-sync-external-store "^1.2.0"
+
"@testing-library/dom@^8.5.0":
version "8.20.1"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f"
@@ -1956,6 +1979,13 @@
dependencies:
"@types/node" "*"
+"@types/date-fns@^2.6.0":
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/@types/date-fns/-/date-fns-2.6.0.tgz#b062ca46562002909be0c63a6467ed173136acc1"
+ integrity sha512-9DSw2ZRzV0Tmpa6PHHJbMcZn79HHus+BBBohcOaDzkK/G3zMjDUDYjJIWBFLbkh+1+/IOS0A59BpQfdr37hASg==
+ dependencies:
+ date-fns "*"
+
"@types/eslint-scope@^3.7.3":
version "3.7.4"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16"
@@ -2128,6 +2158,13 @@
dependencies:
"@types/react" "*"
+"@types/react-helmet@^6.1.6":
+ version "6.1.6"
+ resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.1.6.tgz#7d1afd8cbf099616894e8240e9ef70e3c6d7506d"
+ integrity sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A==
+ dependencies:
+ "@types/react" "*"
+
"@types/react@*", "@types/react@^18.0.0":
version "18.2.21"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9"
@@ -2798,6 +2835,15 @@ axe-core@^4.6.2:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.2.tgz#040a7342b20765cb18bb50b628394c21bccc17a0"
integrity sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==
+axios@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f"
+ integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==
+ dependencies:
+ follow-redirects "^1.15.0"
+ form-data "^4.0.0"
+ proxy-from-env "^1.1.0"
+
axobject-query@^3.1.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a"
@@ -3169,7 +3215,7 @@ check-types@^11.1.1:
resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.2.2.tgz#7afc0b6a860d686885062f2dba888ba5710335b4"
integrity sha512-HBiYvXvn9Z70Z88XKjz3AEKd4HJhBXsa3j7xFnITAzoS8+q6eIGi8qDB8FKPBAjtuxjI/zFpwuiCb8oDtKOYrA==
-chokidar@^3.4.2, chokidar@^3.5.3:
+"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.2, chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -3215,6 +3261,11 @@ cliui@^7.0.2:
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
+clsx@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b"
+ integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
+
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -3639,6 +3690,13 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
+date-fns@*, date-fns@^2.30.0:
+ version "2.30.0"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
+ integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
+ dependencies:
+ "@babel/runtime" "^7.21.0"
+
debug@2.6.9, debug@^2.6.0:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -4628,7 +4686,12 @@ flatted@^3.1.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
-follow-redirects@^1.0.0:
+focus-visible@^5.2.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/focus-visible/-/focus-visible-5.2.0.tgz#3a9e41fccf587bd25dcc2ef045508284f0a4d6b3"
+ integrity sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==
+
+follow-redirects@^1.0.0, follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
@@ -4668,6 +4731,15 @@ form-data@^3.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
+form-data@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
+ integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.8"
+ mime-types "^2.1.12"
+
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
@@ -5135,6 +5207,11 @@ immer@^9.0.7:
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"
integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==
+immutable@^4.0.0:
+ version "4.3.4"
+ resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f"
+ integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==
+
import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -7550,7 +7627,7 @@ prompts@^2.0.1, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
-prop-types@^15.8.1:
+prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -7567,6 +7644,11 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
+proxy-from-env@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+ integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
psl@^1.1.33:
version "1.9.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
@@ -7683,6 +7765,26 @@ react-error-overlay@^6.0.11:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
+react-fast-compare@^3.1.1:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
+ integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
+
+react-helmet@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726"
+ integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==
+ dependencies:
+ object-assign "^4.1.1"
+ prop-types "^15.7.2"
+ react-fast-compare "^3.1.1"
+ react-side-effect "^2.1.0"
+
+react-hook-form@^7.45.4:
+ version "7.45.4"
+ resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.45.4.tgz#73d228b704026ae95d7e5f7b207a681b173ec62a"
+ integrity sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==
+
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -7703,6 +7805,21 @@ react-refresh@^0.11.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==
+react-router-dom@^6.15.0:
+ version "6.15.0"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.15.0.tgz#6da7db61e56797266fbbef0d5e324d6ac443ee40"
+ integrity sha512-aR42t0fs7brintwBGAv2+mGlCtgtFQeOzK0BM1/OiqEzRejOZtpMZepvgkscpMUnKb8YO84G7s3LsHnnDNonbQ==
+ dependencies:
+ "@remix-run/router" "1.8.0"
+ react-router "6.15.0"
+
+react-router@6.15.0:
+ version "6.15.0"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.15.0.tgz#bf2cb5a4a7ed57f074d4ea88db0d95033f39cac8"
+ integrity sha512-NIytlzvzLwJkCQj2HLefmeakxxWHWAP+02EGqWEZy+DgfHHKQMUoBBjUQLOtFInBMhWtb3hiUy6MfFgwLjXhqg==
+ dependencies:
+ "@remix-run/router" "1.8.0"
+
react-scripts@5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003"
@@ -7758,6 +7875,11 @@ react-scripts@5.0.1:
optionalDependencies:
fsevents "^2.3.2"
+react-side-effect@^2.1.0:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a"
+ integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==
+
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
@@ -8047,7 +8169,7 @@ safe-regex-test@^1.0.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
-sanitize.css@*:
+sanitize.css@*, sanitize.css@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/sanitize.css/-/sanitize.css-13.0.0.tgz#2675553974b27964c75562ade3bd85d79879f173"
integrity sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==
@@ -8060,6 +8182,15 @@ sass-loader@^12.3.0:
klona "^2.0.4"
neo-async "^2.6.2"
+sass@^1.66.1:
+ version "1.66.1"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.66.1.tgz#04b51c4671e4650aa393740e66a4e58b44d055b1"
+ integrity sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==
+ dependencies:
+ chokidar ">=3.0.0 <4.0.0"
+ immutable "^4.0.0"
+ source-map-js ">=0.6.2 <2.0.0"
+
sax@~1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@@ -8266,7 +8397,7 @@ source-list-map@^2.0.0, source-list-map@^2.0.1:
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
-source-map-js@^1.0.1, source-map-js@^1.0.2:
+"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
@@ -8790,6 +8921,11 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
+ts-key-enum@^2.0.12:
+ version "2.0.12"
+ resolved "https://registry.yarnpkg.com/ts-key-enum/-/ts-key-enum-2.0.12.tgz#4f7f35eb041fa5847f8f9ed8c38beaaa047a33ba"
+ integrity sha512-Ety4IvKMaeG34AyXMp5r11XiVZNDRL+XWxXbVVJjLvq2vxKRttEANBE7Za1bxCAZRdH2/sZT6jFyyTWxXz28hw==
+
tsconfig-paths@^3.14.2:
version "3.14.2"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088"
@@ -8991,6 +9127,11 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
requires-port "^1.0.0"
+use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
+ integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
+
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -9570,3 +9711,10 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+
+zustand@^4.4.1:
+ version "4.4.1"
+ resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.1.tgz#0cd3a3e4756f21811bd956418fdc686877e8b3b0"
+ integrity sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==
+ dependencies:
+ use-sync-external-store "1.2.0"