commit 79c6987f9707b8facd5d0c9f4940f13e00fe111c Author: pavel Date: Sat Dec 20 06:52:02 2025 +0300 СОздан проект + первые страницы + авторизация diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..3972e33 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,34 @@ +name: Node.js CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - name: install modules + run: npm install + - name: build project + run: npm run build + if: always() + - name: linting typescript + run: npm run types-check + if: always() + - name: linting css + run: npm run stylelint \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c4c8b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +.vscode/* +!.vscode/extensions.json +.idea +.github +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env +package-lock.json +./src/shared/api/constants.ts diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..edeb6b1 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,130 @@ +import pluginJs from "@eslint/js"; +import pluginImport from "eslint-plugin-import"; +import pluginReact from "eslint-plugin-react"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { files: ["**/*.{js,cjs,ts,jsx,tsx}"] }, + { ignores: [ "eslint.config.mjs", "stylelint.config.mjs", "dist", "node_modules", "storybook-static" ] + }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ...pluginReact.configs.flat.recommended, + rules: { + ...pluginReact.configs.flat.recommended.rules, + "react/react-in-jsx-scope": "off", + "react/display-name": "off" + } + }, + { + plugins: { + import: pluginImport, + 'custom-blank-lines': { // Объявляем плагин + rules: { + 'enforce-one-blank-line-after-imports': { + meta: { + type: 'layout', + docs: { + description: 'Enforce only one blank line between imports and the first function.', + category: 'Stylistic Issues', + recommended: 'warn', + }, + fixable: 'whitespace', + }, + create: function(context) { + return { + Program(node) { + const sourceCode = context.getSourceCode(); + const importNodes = node.body.filter(node => node.type === 'ImportDeclaration'); + + const lastImport = importNodes[importNodes.length - 1]; + if (importNodes.length === 0) return; + + let firstFunction = node.body.find( + n => n.type === 'FunctionDeclaration' || + n.type === 'VariableDeclaration' && + n.declarations && + n.declarations[0] && + n.declarations[0].init && + n.declarations[0].init.type === 'ArrowFunctionExpression' + ); + + if (!firstFunction) return; + + const importEndLine = lastImport.loc.end.line; + const firstFunctionStartLine = firstFunction.loc.start.line; + + const blankLines = firstFunctionStartLine - importEndLine - 1; + if (blankLines > 1) { + const rangeToRemove = [ + sourceCode.getIndexFromLoc({ line: importEndLine + 1, column: 0 }), + sourceCode.getIndexFromLoc({ line: firstFunctionStartLine - 1, column: 0 }) + ] + console.log('rangeToRemove', rangeToRemove) + + context.report({ + node, + message: 'Expected only one blank line between imports and the first function.', + fix: (fixer) => { + + const leadingNewLine = sourceCode.getText().substring( + sourceCode.getIndexFromLoc({ line:importEndLine, column:0 }), + sourceCode.getIndexFromLoc({ line:importEndLine + 1, column:0 }), + ).includes('\n') ? '\n' : ''; + + return fixer.replaceTextRange(rangeToRemove, leadingNewLine + '\n'); + } + }); + } + } + }; + }, + }, + }, + }, + }, + rules: { + "import/order": [ + "error", + { + groups: [ + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + "object", + "type", + ], + pathGroups: [ + { + pattern: '@/**', + group: 'internal', + position: 'before' + }, + ], + "newlines-between": "always", + alphabetize: { order: "asc", caseInsensitive: true }, + }, + ], + "object-curly-spacing": ["error", "always"], + "max-len": ["error", { + "code": 180, + "tabWidth": 4, + "comments": 65, + "ignoreComments": true, + "ignoreTrailingComments": true, + "ignoreUrls": true + }], + "no-console": "error", + "no-debugger": "error", + "no-multi-spaces": "error", + "custom-blank-lines/enforce-one-blank-line-after-imports": "error", + }, + }, +]; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..27ba5a6 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + User Request Supplier Portal + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..5fbf671 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "vite-react-typescript-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "type-check": "tsc --noEmit -p tsconfig.app.json", + "cache-clean": "npm cache clean --force", + "eslint": "npx eslint .", + "eslint:fix": "npx eslint . --fix", + "stylelint": "npx stylelint \"**/*.scss\"", + "stylelint:fix": "npx stylelint \"**/*.scss\" --fix", + "precomit-check": "npm run type-check && npm run eslint && npm run stylelint" + }, + "dependencies": { + "@reduxjs/toolkit": "^2.3.0", + "@supabase/supabase-js": "^2.57.4", + "axios": "^1.13.2", + "crypto-js": "^4.2.0", + "lucide-react": "^0.344.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-redux": "^9.1.2", + "react-router": "^7.10.1", + "react-router-dom": "^7.10.1", + "sass": "^1.83.4" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/react-redux": "^7.1.34", + "@types/react-router-dom": "^5.3.3", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.18", + "eslint": "^9.18.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.11", + "eslint-plugin-storybook": "^0.11.2", + "globals": "^15.14.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "postcss": "^8.4.35", + "sass-embedded": "^1.83.4", + "storybook": "^8.5.3", + "stylelint": "^16.13.2", + "stylelint-config-standard-scss": "^14.0.0", + "stylelint-scss": "^6.10.1", + "tailwindcss": "^3.4.1", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.6.3", + "typescript-eslint": "^8.19.1", + "vite": "^5.4.11", + "vite-plugin-svgr": "^4.3.0", + "vite-tsconfig-paths": "^5.1.4" + }, + "eslintConfig": { + "extends": [ + "plugin:storybook/recommended" + ] + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..7b75c83 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx new file mode 100644 index 0000000..4f96843 --- /dev/null +++ b/src/app/ui/App.tsx @@ -0,0 +1,74 @@ +import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; + +import { Layout } from "@/app/ui/Layout"; +import { HomePage } from "@/pages/home"; +import { LoginPage } from "@/pages/login"; +import { MailingById, MailingsPage } from "@/pages/mailings"; +import { RegisterPage } from "@/pages/register"; +import { ResetPasswordPage } from "@/pages/reset-password"; +import { SuppliersPage } from "@/pages/suppliers"; +import { SuppliersSearchPage } from "@/pages/suppliers-search"; +import { AuthProvider, SuppliersSearchProvider, useAuth } from "@/shared/model"; +import { UILoader } from "@/shared/ui"; + +const AppContent = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ( + + ); + } + + return ( + + {/* Публичные маршруты */} + {!user ? ( + <> + } /> + } /> + } /> + } /> + + ) : ( + /* Защищенные маршруты */ + }> + } /> + + + + } + /> + + }> + } /> + } /> + + + }> + } /> + } /> + + + } /> + } /> + + )} + + ); +} + +const App = () => { + return ( + + + + + + ); +} + +export default App; \ No newline at end of file diff --git a/src/app/ui/Layout.tsx b/src/app/ui/Layout.tsx new file mode 100644 index 0000000..081977c --- /dev/null +++ b/src/app/ui/Layout.tsx @@ -0,0 +1,39 @@ +import { Menu } from 'lucide-react'; +import { useState } from 'react'; +import { Outlet } from 'react-router-dom'; + +import { Sidebar } from '@/widgets/sidebar' + + +export const Layout = () => { + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + + // Функция переключения состояния сайдбара + const toggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; + + // Функция закрытия сайдбара + const closeSidebar = () => { + setIsSidebarOpen(false); + }; + + + return ( +
+ + +
+ {/* Кнопка в хедере только для мобильных */} +
+ toggleSidebar()}/> +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/features/modals/index.ts b/src/features/modals/index.ts new file mode 100644 index 0000000..685d058 --- /dev/null +++ b/src/features/modals/index.ts @@ -0,0 +1 @@ +export { AddCompanyModal } from './ui/add-company/AddCompanyModal' \ No newline at end of file diff --git a/src/features/modals/ui/add-company/AddCompanyModal.tsx b/src/features/modals/ui/add-company/AddCompanyModal.tsx new file mode 100644 index 0000000..8fe736e --- /dev/null +++ b/src/features/modals/ui/add-company/AddCompanyModal.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; + +import { companyApi, ICompanyReq } from "@/shared/api"; +import { + ButtonSize, + ButtonVariant, + InputType, + NotificationAppearance, + UIButton, + UIInput, + UINotification +} from "@/shared/ui"; + +interface IProps { + onClose: () => void, +} +export const AddCompanyModal = (props: IProps) => { + const { onClose } = props; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [companyName, setCompanyName] = useState(""); + const [companyEmail, setCompanyEmail] = useState(""); + const [companyNumber, setCompanyNumber] = useState(""); + + const fetchAddCompany = async () => { + const payload: ICompanyReq = { + name: companyName, + email: companyEmail, + phone: companyNumber, + } + + setLoading(true); + try { + const { data, status } = await companyApi.addCompany(payload) + + if (status === 200 || data?.id) { + setCompanyName('') + setCompanyEmail('') + setCompanyNumber('') + } + + onClose(); + + } catch (error) { + setError('Ошибка добавления компании'); + /* eslint-disable */ + console.error(error); + /* eslint-enable */ + } finally { + setLoading(false); + } + }; + + return ( + <> +

Добавление компании

+ + {error && ( + + )} + +
+ setCompanyName(val)} + placeholder="Название компании" + disabled={loading} + /> + setCompanyEmail(val)} + type={InputType.Email} + placeholder="example@example.ru" + disabled={loading} + /> + setCompanyNumber(val)} + placeholder="+x(xxx)-xxx-xx-xx" + disabled={loading} + /> +
+ +
+ fetchAddCompany()} + > + Добавить + +
+ + ) +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..5374929 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,8 @@ +import { createRoot } from 'react-dom/client'; + +import App from '@/app/ui/App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + +); diff --git a/src/pages/home/index.ts b/src/pages/home/index.ts new file mode 100644 index 0000000..b23e2a3 --- /dev/null +++ b/src/pages/home/index.ts @@ -0,0 +1 @@ +export { HomePage } from './ui/Home'; diff --git a/src/pages/home/ui/Home.tsx b/src/pages/home/ui/Home.tsx new file mode 100644 index 0000000..38a21b6 --- /dev/null +++ b/src/pages/home/ui/Home.tsx @@ -0,0 +1,9 @@ +export const HomePage = () => { + return ( +
+
+

Дашборд

+
+
+ ); +} diff --git a/src/pages/login/index.ts b/src/pages/login/index.ts new file mode 100644 index 0000000..a0f453b --- /dev/null +++ b/src/pages/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from './ui/Login' diff --git a/src/pages/login/ui/Login.tsx b/src/pages/login/ui/Login.tsx new file mode 100644 index 0000000..9911d64 --- /dev/null +++ b/src/pages/login/ui/Login.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Link, useNavigate } from "react-router"; + +import { authApi } from "@/shared/api/auth/auth-api"; +import { ROUTES, useAuth } from "@/shared/model"; +import { ButtonType, InputType, UIButton, UIInput } from "@/shared/ui"; + +export const LoginPage = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { updateUserContext } = useAuth(); + const navigate = useNavigate(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const { data } = await authApi.signIn(email, password); + + if (data.accessToken && data.refreshToken) { + window.localStorage.setItem("accessToken", data.accessToken); + window.localStorage.setItem("refreshToken", data.refreshToken); + updateUserContext({ user: data.user }) + navigate(ROUTES.HOME, { replace: true }); + } + // После успешного входа перенаправление произойдет автоматически через AuthContext + } catch (err) { + setError('Ошибка входа. Неверный логин или пароль.'); + + /* eslint-disable */ + console.error(err.message); + /* eslint-enable */ + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Вход

+

Войдите в свой аккаунт

+
+ +
handleLogin(e)} className="space-y-6"> + {error && ( +
+ {error} +
+ )} + + setEmail(val)} + placeholder="your@email.com" + disabled={loading} + required + /> + + setPassword(val)} + placeholder="••••••••" + disabled={loading} + required + minLength={6} + /> + + + Забыли пароль? + + + + {loading ? 'Вход...' : 'Войти'} + + + +
+ Нет аккаунта? + + Зарегистрироваться + +
+
+
+ ); +} diff --git a/src/pages/mailings/index.ts b/src/pages/mailings/index.ts new file mode 100644 index 0000000..077aee1 --- /dev/null +++ b/src/pages/mailings/index.ts @@ -0,0 +1,2 @@ +export { MailingsPage } from './ui/Mailings'; +export { MailingById } from './ui/MailingById'; diff --git a/src/pages/mailings/ui/MailingById.tsx b/src/pages/mailings/ui/MailingById.tsx new file mode 100644 index 0000000..35b3fd3 --- /dev/null +++ b/src/pages/mailings/ui/MailingById.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router"; +import { useNavigate } from "react-router-dom"; + +import { IMailingInfo, ISupplier, mailingApi } from "@/shared/api"; +import { ROUTES } from "@/shared/model/routes"; +import { + ButtonVariant, Column, + NotificationAppearance, + UIButton, + UILoader, + UINotification, + UITable, + UITextArea +} from "@/shared/ui"; + +export const MailingById = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [errorSendMessage, setErrorSendMessage] = useState(''); + + const [mailing, setMailing] = useState(null); + const [selectedCompanies, setSelectedCompanies] = useState([]); + const [message, setMessage] = useState(''); + + const params = useParams(); + const navigate = useNavigate(); + + // const mockDataSup: ISupplier[] = [ + // { company_id: 1, name: 'запрос 1', email: '5@mail.ru', phone: '78974156135', url: 'url.com' }, + // { company_id: 2, name: 'запрос 2', email: '6@mail.ru', phone: '78974156135', url: 'url.com' }, + // { company_id: 3, name: 'запрос 3', email: '5@mail.ru', phone: '78974156135', url: 'url.com' }, + // { company_id: 4, name: 'запрос 4', email: '2@mail.ru', phone: '78974156135', url: 'url.com' } + // ] + // const mockDataSupMailing: IMailingInfo = { + // request_id: 1234, + // title: "Название Рыссылки", + // suppliers: mockDataSup, + // mail_text: 'какой-то текст' + // } + + const fetchMailingById = async () => { + setLoading(true); + try { + const { data, status } = await mailingApi.getMailingItemById(params?.mailingId) + + if (status === 200) { + setMailing(data); + setMessage(data.mail_text) + } + } catch (error) { + setError('Ошибка получения данных.'); + /* eslint-disable */ + console.error(error); + /* eslint-enable */ + } finally { + setLoading(false); + } + }; + + const handleSelectionChange = useCallback((rows: ISupplier[]) => { + setSelectedCompanies(rows); + },[]); + + // запрашиваем весь список компаний + useEffect(() => { + fetchMailingById(); + }, []) + + + const sendMessages = async () => { + setLoading(true); + try { + const payload = { + id: params?.mailingId, + emails: selectedCompanies.map((item) => item.email), + mail_text: message + } + const { status } = await mailingApi.sendMailing(payload) + + if (status === 200) { + navigate(ROUTES.MAILINGS); + } + } catch (error) { + setErrorSendMessage('Ошибка отправки рассылки.'); + /* eslint-disable */ + console.error(error); + /* eslint-enable */ + } finally { + setLoading(false); + } + } + + + const columns: Column[] = [ + { key: 'name', title: 'Название компании', maxWidth: 180 }, + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Номер телефона' }, + { key: 'url', title: 'Сайт компании' }, + ] + + const tableData = mailing?.suppliers.map((supplier: ISupplier) => { + return { + company_id: supplier.company_id, + name: supplier.name, + email: supplier.email, + phone: supplier.phone, + url: supplier?.url || '-', + } + }) + + return ( +
+
+ + {loading && ( )} + + {error && ( + + )} + + {mailing && (<> +

Рассылка по запросу: {mailing.title}

+ + + checkable + fixedHeader + columns={columns} + data={tableData} + maxHeight={600} + onSelectionChange={handleSelectionChange} + /> + +

Выбранные компании: {selectedCompanies.length} из {mailing.suppliers.length}

+ +
+ setMessage(val)} + minCount={10} + /> +
+ + {errorSendMessage && ( + + )} + +
+ sendMessages()} + > + Разослать сообщение + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/mailings/ui/Mailings.tsx b/src/pages/mailings/ui/Mailings.tsx new file mode 100644 index 0000000..6593364 --- /dev/null +++ b/src/pages/mailings/ui/Mailings.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { MailingsItem } from "@/pages/mailings/ui/MailingsItem"; +import { IMailingItem, mailingApi } from "@/shared/api"; +import { ROUTES } from "@/shared/model"; +import { + NotificationAppearance, + UILoader, + UINotification, +} from "@/shared/ui"; + +export const MailingsPage = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [mailingList, setMailingList] = useState([]); + const navigate = useNavigate(); + + // const mockMailingList = [ + // { + // request_id: 1234, + // title: 'Запрос на получение ручек 1', + // mailing_status: MailingStatusEnum.NoMailing + // }, + // { + // request_id: 12345, + // title: 'Запрос на получение ручек 2', + // mailing_status: MailingStatusEnum.NoMailing + // }, + // { + // request_id: 12346, + // title: 'Запрос на получение ручек 3', + // mailing_status: MailingStatusEnum.NoMailing + // } + // ] + + const fetchMailings = async () => { + setLoading(true); + try { + const { data, status } = await mailingApi.getAllMailingList() + + if (status === 200) { + setMailingList(data); + } + } catch (error) { + setError('Ошибка получения данных.'); + /* eslint-disable */ + console.error(error); + /* eslint-enable */ + } finally { + // setMailingList(mockMailingList); + setLoading(false); + } + }; + + // запрашиваем весь список компаний + useEffect(() => { + fetchMailings(); + }, []) + + return ( +
+
+

Рассылки

+ + {loading && ( )} + + {error && ( + + )} + + {!mailingList.length && !loading && ( +

У вас пока нет ни одной рассылки.

+ )} + + +
+ {mailingList.length > 0 && ( + mailingList.map((item) => ( + navigate(`${ROUTES.MAILINGS}/${item.request_id}`)} + /> + )) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/mailings/ui/MailingsItem.tsx b/src/pages/mailings/ui/MailingsItem.tsx new file mode 100644 index 0000000..7f824b1 --- /dev/null +++ b/src/pages/mailings/ui/MailingsItem.tsx @@ -0,0 +1,46 @@ +import { Loader, MailCheck, MailX, MailOpen } from "lucide-react"; +import { useMemo } from "react"; + +import { IMailingItem, MailingStatusEnum } from "@/shared/api"; +import { ButtonSize, UIButton } from "@/shared/ui"; + +interface IProps { + item: IMailingItem + onSelectItem: (id: string | number) => void +} + +export const MailingsItem = (props: IProps) => { + const { item, onSelectItem } = props; + + const statusIcon = useMemo(() => { + switch (item.mailing_status) { + case MailingStatusEnum.NoMailing: + return ; + case MailingStatusEnum.InProgress: + return ; + case MailingStatusEnum.Success: + return ; + case MailingStatusEnum.Error: + return ; + default: + return null; + } + }, [item.mailing_status]); + + return ( +
+
+
{statusIcon}
+
{item.title}
+
+ + onSelectItem(item.request_id)} + > + Отправить рассылку + +
+ ) +} \ No newline at end of file diff --git a/src/pages/register/index.ts b/src/pages/register/index.ts new file mode 100644 index 0000000..4293ad7 --- /dev/null +++ b/src/pages/register/index.ts @@ -0,0 +1 @@ +export { RegisterPage } from './ui/Register'; diff --git a/src/pages/register/ui/Register.tsx b/src/pages/register/ui/Register.tsx new file mode 100644 index 0000000..a0b60c0 --- /dev/null +++ b/src/pages/register/ui/Register.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { Link } from "react-router"; +import { useNavigate } from "react-router-dom"; + +import { authApi } from '@/shared/api'; +import { ROUTES, useAuth } from "@/shared/model"; +import { ButtonType, InputType, UIButton, UIInput, UIPhoneNumber } from '@/shared/ui'; + +export const RegisterPage = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + // 4c5fb6b8-a0b3-495e-9afb-37a264e7a996 постоянный код для теста + const [inviteCode, setInviteCode] = useState(''); + const [userName, setUserName] = useState(''); + const [phone, setPhone] = useState('+7(___)___-__-__'); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { updateUserContext } = useAuth(); + const navigate = useNavigate(); + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const { data } = await authApi.signUp({ + email, + password, + inviteCode, + name: userName, + phone, + }); + + if (data.accessToken && data.refreshToken) { + window.localStorage.setItem("accessToken", data.accessToken); + window.localStorage.setItem("refreshToken", data.refreshToken); + + updateUserContext({ user: data.user }) + navigate(ROUTES.HOME, { replace: true }); + } + + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка регистрации'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ Регистрация +

+

+ Создайте новый аккаунт +

+
+ +
handleRegister(e)} className="space-y-4"> + {error && ( +
+ {error} +
+ )} + + setEmail(val)} + placeholder="your@email.com" + disabled={loading} + required + /> + + setPassword(val)} + placeholder="••••••••" + disabled={loading} + required + minLength={6} + /> + + setInviteCode(val)} + placeholder="Введите код" + disabled={loading} + required + /> + + setUserName(val)} + placeholder="Иван Петров" + disabled={loading} + required + /> + + setPhone(val)} + /> + + + {loading ? 'Регистрация...' : 'Зарегистрироваться'} + + + +
+
Уже есть аккаунт?
+ + Войти + +
+
+
+ ); +}; diff --git a/src/pages/reset-password/index.ts b/src/pages/reset-password/index.ts new file mode 100644 index 0000000..ae11cdd --- /dev/null +++ b/src/pages/reset-password/index.ts @@ -0,0 +1 @@ +export { ResetPasswordPage } from './ui/ResetPassword'; diff --git a/src/pages/reset-password/ui/ResetPassword.tsx b/src/pages/reset-password/ui/ResetPassword.tsx new file mode 100644 index 0000000..d23cbf7 --- /dev/null +++ b/src/pages/reset-password/ui/ResetPassword.tsx @@ -0,0 +1,90 @@ +import { ArrowLeft } from "lucide-react"; +import { useState } from 'react'; +import { Link } from "react-router"; + +import { ROUTES } from "@/shared/model"; +import { ButtonType, InputType, NotificationAppearance, UIButton, UIInput, UINotification } from "@/shared/ui"; + + +export const ResetPasswordPage = () => { + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + const handleReset = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(false); + setLoading(true); + + try { + // const { error } = await supabase.auth.resetPasswordForEmail(email, { + // redirectTo: `${window.location.origin}/reset-password`, + // }); + + if (error) throw error; + setSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка сброса пароля'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Сброс пароля

+

Восстановите доступ к аккаунту

+
+ +
handleReset(e)} className="space-y-6"> + {error && ( + + )} + + {success && ( + + )} + + setEmail(val)} + placeholder="your@email.com" + disabled={loading} + required + /> + + + {loading ? 'Отправка...' : 'Отправить письмо'} + + + + + Вернуться к входу + +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/suppliers-search/index.ts b/src/pages/suppliers-search/index.ts new file mode 100644 index 0000000..235b26c --- /dev/null +++ b/src/pages/suppliers-search/index.ts @@ -0,0 +1,3 @@ +export { SuppliersSearchPage } from './ui/SuppliersSearchPage'; + +export { StagesEnum } from './model/types' diff --git a/src/pages/suppliers-search/model/types.ts b/src/pages/suppliers-search/model/types.ts new file mode 100644 index 0000000..417b1b0 --- /dev/null +++ b/src/pages/suppliers-search/model/types.ts @@ -0,0 +1,5 @@ +export const enum StagesEnum { + Input = 'input', + TZ = 'tz', + Suppliers = 'suppliers', +} \ No newline at end of file diff --git a/src/pages/suppliers-search/ui/RequestInputForm.tsx b/src/pages/suppliers-search/ui/RequestInputForm.tsx new file mode 100644 index 0000000..91f8b8d --- /dev/null +++ b/src/pages/suppliers-search/ui/RequestInputForm.tsx @@ -0,0 +1,114 @@ +import { Send } from 'lucide-react'; +import { useState } from "react"; + +import { suppliersSearchApi } from '@/shared/api'; +import { useAuth, useSuppliersSearch } from "@/shared/model"; +import { + ButtonVariant, + NotificationAppearance, + UIButton, + UILoader, + UINotification, + UITextArea, +} from '@/shared/ui'; + +interface RequestInputFormProps { + onSuccess?: () => void; +} + +export const RequestInputForm = (props: RequestInputFormProps) => { + const { onSuccess } = props; + const { + requestText, + updateSuppliersSearchContext, + } = useSuppliersSearch(); + const { user } = useAuth(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (hasTz?: boolean) => { + setLoading(true); + setError(''); + + try { + const payloadText = hasTz ? '' : requestText; + + const { data } = await suppliersSearchApi.sendCreateTzWebhook({ + requestTxt: payloadText, + user_id: user.id, + }); + + if (data?.request_id) { + updateSuppliersSearchContext?.({ + requestId: data.request_id, + tzText: data.tz_text + }); + + onSuccess?.(); + } + } catch (err) { + setError('Ошибка отправки запроса'); + /* eslint-disable */ + console.error(err); + /* eslint-enable */ + } finally { + setLoading(false); + } + }; + + return ( +
+

Введите запрос на поиск товаров или услуг

+ + + {loading && } + + {error && ( + + )} + + {!loading && ( + <> + + updateSuppliersSearchContext?.({ requestText: val }) + } + /> + +
+ handleSubmit(true)} + className="mt-4" + > + У меня есть ТЗ + + + handleSubmit()} + className="mt-4" + > + Отправить + +
+ + + )} +
+ ); +}; diff --git a/src/pages/suppliers-search/ui/SuppliersSearchPage.tsx b/src/pages/suppliers-search/ui/SuppliersSearchPage.tsx new file mode 100644 index 0000000..6e05b3b --- /dev/null +++ b/src/pages/suppliers-search/ui/SuppliersSearchPage.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; + +import { StagesEnum } from "@/pages/suppliers-search"; +import { RequestInputForm } from "@/pages/suppliers-search/ui/RequestInputForm"; +import { SuppliersTable } from "@/pages/suppliers-search/ui/SuppliersTable"; +import { TechnicalSpecification } from "@/pages/suppliers-search/ui/TechnicalSpecification"; +import { useSuppliersSearch } from "@/shared/model"; +import { UIStepper } from "@/shared/ui"; + +export const SuppliersSearchPage = () => { + const [stage, setStage] = useState(StagesEnum.Input); + const { suppliers, updateSuppliersSearchContext } = useSuppliersSearch(); + + const steps = [ + { text: 'Поиск', value: StagesEnum.Input }, + { text: 'ТЗ по запросу', value: StagesEnum.TZ }, + { text: 'Поставщики', value: StagesEnum.Suppliers }, + ]; + + const handleStepChange = (nextStage: StagesEnum) => { + const currentIndex = steps.findIndex( + s => s.value === stage, + ); + const nextIndex = steps.findIndex( + s => s.value === nextStage, + ); + + if (nextIndex <= currentIndex) { + setStage(nextStage); + } + } + + const clearSearchSuppliersContext = () => { + if (suppliers.length === 0) { + setStage(StagesEnum.Input); + } else { + updateSuppliersSearchContext({ + tzText: '', + requestText: '', + suppliers: [], + requestId: null + }) + + setStage(StagesEnum.TZ); + } + + } + + const renderStage = () => { + switch (stage) { + case StagesEnum.Input: + return ( + setStage(StagesEnum.TZ)} + /> + ); + + case StagesEnum.TZ: + return ( + setStage(StagesEnum.Input)} + onSuccess={() => setStage(StagesEnum.Suppliers)} + /> + ); + + case StagesEnum.Suppliers: + return ( + clearSearchSuppliersContext()} + /> + ); + } + }; + + + return ( +
+ handleStepChange(nextStage)} + /> + + {renderStage()} +
+ ); +} diff --git a/src/pages/suppliers-search/ui/SuppliersTable.tsx b/src/pages/suppliers-search/ui/SuppliersTable.tsx new file mode 100644 index 0000000..77cda8a --- /dev/null +++ b/src/pages/suppliers-search/ui/SuppliersTable.tsx @@ -0,0 +1,88 @@ +import { ArrowBigLeft } from "lucide-react"; + +import { ISupplier } from "@/shared/api"; +import { useSuppliersSearch } from "@/shared/model"; +import { + ButtonSize, + ButtonVariant, + Column, + UIButton, + UITable +} from "@/shared/ui"; + +interface ISuppliersTableProps { + onSuccess?: () => void; + onBack?: () => void; +} + +export const SuppliersTable = (props: ISuppliersTableProps) => { + const { onBack } = props; + const { suppliers } = useSuppliersSearch(); + + const columns: Column[] = [ + { key: 'name', title: 'Название компании', maxWidth: 180 }, + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Номер телефона' }, + { key: 'url', title: 'Сайт компании' }, + ] + + // const mockDataSup: ISupplier[] = [ + // { company_id: 1, name: 'запрос 1', email: '5@mail.ru', phone: '78974156135', url: 'url.com' }, + // { company_id: 2, name: 'запрос 2', email: '6@mail.ru', phone: '78974156135', url: 'url.com' }, + // { company_id: 3, name: 'запрос 3', email: '5@mail.ru', phone: '78974156135', url: 'url.com' }, + // { company_id: 4, name: 'запрос 4', email: '2@mail.ru', phone: '78974156135', url: 'url.com' } + // ] + + const tableData = suppliers.map((supplier: ISupplier) => { + return { + company_id: supplier.company_id, + name: supplier.name, + email: supplier.email, + phone: supplier.phone, + url: supplier?.url, + } + }) + + return ( +
+

Список поставщиков

+ + {suppliers.length > 0 && ( + <> + + fixedHeader + columns={columns} + data={tableData} + maxHeight={600} + /> + +
+ onBack()} + > + Новый поиск + +
+ + )} + + {suppliers.length === 0 && ( + <> +

Поставщики не найдены. Повторите запрос позже.

+ + onBack()} + > + Назад + + + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/pages/suppliers-search/ui/TechnicalSpecification.tsx b/src/pages/suppliers-search/ui/TechnicalSpecification.tsx new file mode 100644 index 0000000..37d3f7e --- /dev/null +++ b/src/pages/suppliers-search/ui/TechnicalSpecification.tsx @@ -0,0 +1,100 @@ +import { ArrowBigLeft, Search } from 'lucide-react'; +import { useState } from 'react'; + +import { suppliersSearchApi } from '@/shared/api'; +import { useAuth, useSuppliersSearch } from "@/shared/model"; +import { + ButtonVariant, + NotificationAppearance, + UIButton, + UINotification, + UITextArea, +} from '@/shared/ui'; + +interface TechnicalSpecificationProps { + onSuccess?: () => void; + onBack?: () => void; +} + +export const TechnicalSpecification = (props: TechnicalSpecificationProps) => { + const { onSuccess, onBack } = props; + + const { tzText, requestId, updateSuppliersSearchContext } = useSuppliersSearch(); + const { user } = useAuth(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleApprove = async () => { + setLoading(true); + setError(''); + + try { + const { data } = await suppliersSearchApi.sendApproveTzWebhook({ requestId, tzText, user_id: user.id }); + if (data?.request_id) { + updateSuppliersSearchContext?.({ + requestId: data.request_id, + suppliers: data.suppliers + }); + + onSuccess?.(); + } + } catch (e) { + setError('Ошибка согласования ТЗ'); + /* eslint-disable */ + console.error(e); + /* eslint-enable */ + } finally { + setLoading(false); + } + }; + + return ( +
+

+ {tzText.length ? 'Отредактируйте и согласуйте ТЗ' : 'Введите ТЗ'} +

+ + {error && ( + + )} + + <> + + updateSuppliersSearchContext?.({ tzText: value }) + } + disabled={loading} + placeholder="Текст технического задания..." + rows={16} + className="h-96 mb-6" + /> + +
+ onBack()} + > + Назад + + + handleApprove()} + > + {loading ? 'Поиск...' : 'Поиск'} + +
+ +
+ ); +}; diff --git a/src/pages/suppliers/index.ts b/src/pages/suppliers/index.ts new file mode 100644 index 0000000..180e88a --- /dev/null +++ b/src/pages/suppliers/index.ts @@ -0,0 +1 @@ +export { SuppliersPage } from './ui/Suppliers'; diff --git a/src/pages/suppliers/ui/Suppliers.tsx b/src/pages/suppliers/ui/Suppliers.tsx new file mode 100644 index 0000000..8d07320 --- /dev/null +++ b/src/pages/suppliers/ui/Suppliers.tsx @@ -0,0 +1,10 @@ +export const SuppliersPage = () => { + return ( +
+
+

Поставщики

+

Страница в разработке

+
+
+ ); +} diff --git a/src/pages/viewing-requests/index.ts b/src/pages/viewing-requests/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/viewing-requests/ui/ViewingRequests.tsx b/src/pages/viewing-requests/ui/ViewingRequests.tsx new file mode 100644 index 0000000..7ad4cdb --- /dev/null +++ b/src/pages/viewing-requests/ui/ViewingRequests.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { MailingsItem } from "@/pages/mailings/ui/MailingsItem"; +import { IMailingItem, viewingRequestsApi } from "@/shared/api"; +import { ROUTES } from "@/shared/model/routes"; +import { + NotificationAppearance, + UILoader, + UINotification, +} from "@/shared/ui"; + +export const ViewingRequestsPage = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [mailingList, setMailingList] = useState([]); + const navigate = useNavigate(); + + // const mockMailingList = [ + // { + // request_id: 1234, + // title: 'Запрос на получение ручек 1', + // mailing_status: MailingStatusEnum.NoMailing + // }, + // { + // request_id: 12345, + // title: 'Запрос на получение ручек 2', + // mailing_status: MailingStatusEnum.NoMailing + // }, + // { + // request_id: 12346, + // title: 'Запрос на получение ручек 3', + // mailing_status: MailingStatusEnum.NoMailing + // } + // ] + + const fetchMailings = async () => { + setLoading(true); + try { + const { data, status } = await viewingRequestsApi.getAllMailingList() + + if (status === 200) { + setMailingList(data); + } + } catch (error) { + setError('Ошибка получения данных.'); + /* eslint-disable */ + console.error(error); + /* eslint-enable */ + } finally { + // setMailingList(mockMailingList); + setLoading(false); + } + }; + + // запрашиваем весь список компаний + useEffect(() => { + fetchMailings(); + }, []) + + return ( +
+
+

Рассылки

+ + {loading && ( )} + + {error && ( + + )} + + {!mailingList.length && !loading && ( +

У вас пока нет ни одной рассылки.

+ )} + + +
+ {mailingList.length > 0 && ( + mailingList.map((item) => ( + navigate(`${ROUTES.VIEWING_REQUESTS}/${item.request_id}`)} + /> + )) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/shared/api/api-client.ts b/src/shared/api/api-client.ts new file mode 100644 index 0000000..9ddc5cf --- /dev/null +++ b/src/shared/api/api-client.ts @@ -0,0 +1,136 @@ +import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestConfig, AxiosRequestHeaders } from 'axios'; + +import { VITE_N8N_BASE_URL } from "@/shared/api/constants"; + +// Тип для токена авторизации +// interface AuthToken { +// access_token: string; +// [key: string]: unknown; // Другие возможные поля токена +// } + +// Тип для ошибки в ответе API +interface ApiErrorData { + error?: string; + message?: string; + [key: string]: unknown; // Дополнительные поля ошибки +} + +class ApiClient { + private readonly client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: VITE_N8N_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + }); + + this.client.interceptors.request.use( + (config) => { + const token = this.getAuthToken(); + if (token) { + config.headers = { + ...config.headers, + Authorization: `Bearer ${token}`, + } as unknown as AxiosRequestHeaders; + } + return config; + }, + (error) => Promise.reject(error) + ); + + this.client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response) { + const errorData = error.response.data || {}; + const errorMessage = errorData.error || errorData.message || + `HTTP ${error.response.status}: ${error.response.statusText}`; + + return Promise.reject(new Error(errorMessage)); + } + + if (error.request) { + return Promise.reject(new Error('Network error: No response received')); + } + + return Promise.reject(error); + } + ); + } + + private getAuthToken(): string | null { + try { + // const tokenStr = localStorage.getItem('supabase.auth.token'); + const tokenStr = window.localStorage.getItem("accessToken"); + if (!tokenStr) return null; + + // const token: AuthToken = JSON.parse(tokenStr); + return tokenStr || null; + } catch (error) { + /* eslint-disable */ + console.error('Error parsing auth token:', error); + /* eslint-enable */ + + return null; + } + } + + private getHeaders(): Record { + const headers: Record = {}; + const token = this.getAuthToken(); + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; + } + + get(url: string, config?: AxiosRequestConfig): Promise> { + return this.client.get(url, { + ...config, + headers: { ...this.getHeaders(), ...config?.headers }, + }); + } + + post(url: string, body?: unknown, config?: AxiosRequestConfig): Promise> { + return this.client.post(url, body, { + ...config, + headers: { ...this.getHeaders(), ...config?.headers }, + }); + } + + put(url: string, body?: unknown, config?: AxiosRequestConfig): Promise> { + return this.client.put(url, body, { + ...config, + headers: { ...this.getHeaders(), ...config?.headers }, + }); + } + + delete(url: string, config?: AxiosRequestConfig): Promise> { + return this.client.delete(url, { + ...config, + headers: { ...this.getHeaders(), ...config?.headers }, + }); + } + + patch(url: string, body?: unknown, config?: AxiosRequestConfig): Promise> { + return this.client.patch(url, body, { + ...config, + headers: { ...this.getHeaders(), ...config?.headers }, + }); + } + + async handleResponse(response: AxiosResponse): Promise { + return response.data; + } + + async extractData(promise: Promise>): Promise { + const response = await promise; + return response.data; + } +} + +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/src/shared/api/auth/auth-api.ts b/src/shared/api/auth/auth-api.ts new file mode 100644 index 0000000..b586653 --- /dev/null +++ b/src/shared/api/auth/auth-api.ts @@ -0,0 +1,35 @@ +import { apiClient, ISignUpPayload, IAuthSuccessResponse, IUserSchema } from "@/shared/api"; + +class AuthApi { + async signUp(payload: ISignUpPayload) { + return await apiClient.post('/b2b/register', payload) + } + + async signIn(email: string, password: string) { + return await apiClient.post('/b2b/login', { + email, + password, + }) + } + + async signOut(refreshToken: string) { + return await apiClient.post<{success: boolean}>('/b2b/logout', { + refreshToken + }) + } + + + + async validateToken() { + return await apiClient.get<{success: boolean, user: IUserSchema}>('/b2b/validate') + } + + async refreshToken(payload: { refreshToken: string}) { + return await apiClient.post<{accessToken: string}>('/b2b/refresh', payload) + } + + // async resetPassword(email: string) { + // // return await supabase.auth.resetPasswordForEmail(email); + // } +} +export const authApi = new AuthApi(); diff --git a/src/shared/api/auth/types.ts b/src/shared/api/auth/types.ts new file mode 100644 index 0000000..359c71d --- /dev/null +++ b/src/shared/api/auth/types.ts @@ -0,0 +1,20 @@ +export interface ISignUpPayload { + email: string, + password: string, + name: string, + phone: string, + inviteCode: string, +} + +export interface IUserSchema { + id: string, + email: string, + name: string, + phone?: string, +} + +export interface IAuthSuccessResponse { + accessToken: string, + refreshToken: string, + user: IUserSchema, +} \ No newline at end of file diff --git a/src/shared/api/company/company-api.ts b/src/shared/api/company/company-api.ts new file mode 100644 index 0000000..be624c1 --- /dev/null +++ b/src/shared/api/company/company-api.ts @@ -0,0 +1,9 @@ +import { apiClient, ICompanyReq, ICompanyRes } from "@/shared/api"; + +class CompanyApi { + async addCompany(company: ICompanyReq) { + return await apiClient.post('add-company', company); + } +} + +export const companyApi = new CompanyApi(); \ No newline at end of file diff --git a/src/shared/api/company/types.ts b/src/shared/api/company/types.ts new file mode 100644 index 0000000..6b8f6e5 --- /dev/null +++ b/src/shared/api/company/types.ts @@ -0,0 +1,9 @@ +export interface ICompany { + id: string | number; + name: string; + email: string; + phone?: string; +} + +export type ICompanyRes = Pick +export type ICompanyReq = Omit \ No newline at end of file diff --git a/src/shared/api/constants.ts b/src/shared/api/constants.ts new file mode 100644 index 0000000..c4061d1 --- /dev/null +++ b/src/shared/api/constants.ts @@ -0,0 +1,8 @@ +// @ts-expect-error -- импорт из env +export const VITE_SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +// @ts-expect-error -- импорт из env +export const VITE_SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY; +// @ts-expect-error -- импорт из env +export const VITE_N8N_BASE_URL = import.meta.env.VITE_N8N_BASE_URL; +// @ts-expect-error -- импорт из env +export const VITE_ENCYPTO_KEY = import.meta.env.VITE_ENCYPTO_KEY; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 0000000..1b7c6d2 --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1,16 @@ +export { RequestMethods, RequestStatus, type IErrorResponseShema } from "./types"; +export { type ISignUpPayload, type IAuthSuccessResponse, type IUserSchema } from '@/shared/api/auth/types' +export { type IRequests, type ICreateRequestData } from '@/shared/api/suppliers-search/types' +export { type ICompany, type ICompanyRes, type ICompanyReq } from './company/types' +export { type IMailingItem, type IMailingInfo, type ISupplier, MailingStatusEnum, type IMailingSendMessageReq } from './mailing/types' + +export { VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_N8N_BASE_URL } from './constants' +export { apiClient } from './api-client' + +export { authApi } from './auth/auth-api' +export { suppliersSearchApi } from './suppliers-search/suppliers-search-api' +export { companyApi } from './company/company-api' +export { mailingApi } from './mailing/mailing-api' +export { viewingRequestsApi } from './viewing-requests/viewing-requests-api' + + diff --git a/src/shared/api/mailing/mailing-api.ts b/src/shared/api/mailing/mailing-api.ts new file mode 100644 index 0000000..f1d9010 --- /dev/null +++ b/src/shared/api/mailing/mailing-api.ts @@ -0,0 +1,22 @@ +import { apiClient, IMailingInfo, IMailingItem, IMailingSendMessageReq } from "@/shared/api"; + +class MailingApi { + // получение списка всех рассылок + async getAllMailingList() { + return await apiClient.get('mailing-list'); + } + + // получение данных о рассылке по id рассылки + async getMailingItemById(id: string | number) { + return await apiClient.post(`mailing-list`, { + request_id: id + }); + } + + // разослать сообщение по выбранным имейлам + async sendMailing(payload: IMailingSendMessageReq) { + return await apiClient.post(`mailing-list/send-mail`, payload); + } +} + +export const mailingApi = new MailingApi(); \ No newline at end of file diff --git a/src/shared/api/mailing/types.ts b/src/shared/api/mailing/types.ts new file mode 100644 index 0000000..57bc9e6 --- /dev/null +++ b/src/shared/api/mailing/types.ts @@ -0,0 +1,33 @@ +export const enum MailingStatusEnum { + NoMailing = 'no_mailing', + InProgress = 'in_progress', + Success = 'success', + Error = 'error', +} + +export interface IMailingItem { + request_id: string | number; + title: string; + mailing_status: MailingStatusEnum; +} + +export interface ISupplier { + company_id: string | number; + email: string; + name: string; + phone: string; + url?: string; +} + +export interface IMailingInfo { + request_id: string | number; + title: string; + suppliers: ISupplier[]; + mail_text: string; +} + +export interface IMailingSendMessageReq { + id: string | number; + emails: string[]; + mail_text: string; +} \ No newline at end of file diff --git a/src/shared/api/suppliers-search/suppliers-search-api.ts b/src/shared/api/suppliers-search/suppliers-search-api.ts new file mode 100644 index 0000000..385e058 --- /dev/null +++ b/src/shared/api/suppliers-search/suppliers-search-api.ts @@ -0,0 +1,13 @@ +import { apiClient, ISupplier } from "@/shared/api"; + +class SuppliersSearchApi { + async sendCreateTzWebhook(payload: {requestTxt: string, user_id: string | number}) { + return await apiClient.post<{request_id: string | number, tz_text: string}>('create-tz', payload); + } + + async sendApproveTzWebhook(payload: {requestId: string | number | null, tzText: string, user_id: string | number}) { + return await apiClient.post<{request_id: string | number, suppliers: ISupplier[]}>('approve-tz', payload); + } +} + +export const suppliersSearchApi = new SuppliersSearchApi(); \ No newline at end of file diff --git a/src/shared/api/suppliers-search/types.ts b/src/shared/api/suppliers-search/types.ts new file mode 100644 index 0000000..de05bdd --- /dev/null +++ b/src/shared/api/suppliers-search/types.ts @@ -0,0 +1,15 @@ +export interface IRequests { + id: string; + user_id: string; + request_txt: string; + response_txt?: string; + status: string; + created_at: string; +} + + +export interface ICreateRequestData { + user_id: string; + request_txt: string; + status: string; +} \ No newline at end of file diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts new file mode 100644 index 0000000..40e229f --- /dev/null +++ b/src/shared/api/types.ts @@ -0,0 +1,19 @@ +export const enum RequestStatus { + Loading = 'loading', + Resolved = 'resolved', + Fulfilled = 'fulfilled', + Rejected = 'rejected', +} + +export const enum RequestMethods { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Delete = 'DELETE', + Patch = 'PATCH', +} + +export interface IErrorResponseShema { + error: string, + message: string, +} \ No newline at end of file diff --git a/src/shared/api/viewing-requests/viewing-requests-api.ts b/src/shared/api/viewing-requests/viewing-requests-api.ts new file mode 100644 index 0000000..2d83467 --- /dev/null +++ b/src/shared/api/viewing-requests/viewing-requests-api.ts @@ -0,0 +1,22 @@ +import { apiClient, IMailingInfo, IMailingItem, IMailingSendMessageReq } from "@/shared/api"; + +class ViewingRequestsApi { + // получение списка всех рассылок + async getAllMailingList() { + return await apiClient.get('mailing-list'); + } + + // получение данных о рассылке по id рассылки + async getMailingItemById(id: string | number) { + return await apiClient.post(`mailing-list`, { + request_id: id + }); + } + + // разослать сообщение по выбранным имейлам + async sendMailing(payload: IMailingSendMessageReq) { + return await apiClient.post(`mailing-list/send-mail`, payload); + } +} + +export const viewingRequestsApi = new ViewingRequestsApi(); \ No newline at end of file diff --git a/src/shared/lib/classnames/classNames.ts b/src/shared/lib/classnames/classNames.ts new file mode 100644 index 0000000..3790e93 --- /dev/null +++ b/src/shared/lib/classnames/classNames.ts @@ -0,0 +1,31 @@ +export type Mods = Record + +export function classNames( + cls: string, + mods: Mods = {}, + additional: (string | undefined)[] = [], +): string { + const classes: string[] = [cls]; + + // Добавляем дополнительные классы + additional.filter(Boolean).forEach(className => { + if (className) { + classes.push(className); + } + }); + + // Обрабатываем модификаторы + for (const [className, value] of Object.entries(mods)) { + if (value) { + if (typeof value === 'string') { + // Если значение - строка, добавляем её как класс + classes.push(value); + } else { + // Если значение - true, добавляем имя модификатора + classes.push(className); + } + } + } + + return classes.join(' '); +} \ No newline at end of file diff --git a/src/shared/lib/hooks/useClickOutside.ts b/src/shared/lib/hooks/useClickOutside.ts new file mode 100644 index 0000000..66433bf --- /dev/null +++ b/src/shared/lib/hooks/useClickOutside.ts @@ -0,0 +1,24 @@ +import { useEffect, MutableRefObject } from "react"; + +/* + ref - эл-т, при клике вне области которого должен срабатывать колбэк + callback- обработчик клика вне области элемента ref + */ +export const useClickOutside = (ref: MutableRefObject, callback: () => void) => { + // Вызывается, если пользователь щелкает за пределами переданного элемента + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + }; + + useEffect(() => { + // Добавляем слушателя событий для кликов вне ref + document.addEventListener("mousedown", handleClickOutside); + + // Удаляем слушателя при размонтировании компонента + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [ref, callback]); +} \ No newline at end of file diff --git a/src/shared/lib/hooks/useDevices.ts b/src/shared/lib/hooks/useDevices.ts new file mode 100644 index 0000000..86ae882 --- /dev/null +++ b/src/shared/lib/hooks/useDevices.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; + +type UseDevicesType = { + isMobile: boolean; + isDesktop: boolean; +} + +export const useDevices = (): UseDevicesType => { + const [ isMobile, setIsMobile ] = useState(window.innerWidth < 880) + const [ isDesktop, setIsDesktop ] = useState(window.innerWidth > 880) + + const updateIsMobile = () => { + setIsMobile(window.innerWidth < 880) + }; + + const updateIsDesktop = () => { + setIsDesktop(window.innerWidth >= 880) + }; + + useEffect(() => { + updateIsMobile(); + updateIsDesktop(); + + window.addEventListener("resize", updateIsMobile); + window.addEventListener("resize", updateIsDesktop); + + return () => { + window.removeEventListener("resize", updateIsMobile); + window.removeEventListener("resize", updateIsDesktop); + } + }, [isMobile, isDesktop]) + + return { isMobile, isDesktop }; +} \ No newline at end of file diff --git a/src/shared/lib/hooks/useNavigation.ts b/src/shared/lib/hooks/useNavigation.ts new file mode 100644 index 0000000..e054059 --- /dev/null +++ b/src/shared/lib/hooks/useNavigation.ts @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router-dom'; + +export const ROUTES = { + LOGIN: '/login', + REGISTER: '/register', + RESET_PASSWORD: '/reset-password', + HOME: '/', + SUPPLIERS_SEARCH: '/suppliers-search', + MAILINGS: '/mailings', + SUPPLIERS: '/suppliers', +}; + +export type RouteName = keyof typeof ROUTES; + +export const useNavigation = () => { + const navigate = useNavigate(); + + const goTo = (route: RouteName | string) => { + if (route in ROUTES) { + navigate(ROUTES[route as RouteName]); + } else { + navigate(route); + } + }; + + const goBack = () => { + navigate(-1); + }; + + const replace = (route: RouteName | string) => { + if (route in ROUTES) { + navigate(ROUTES[route as RouteName], { replace: true }); + } else { + navigate(route, { replace: true }); + } + }; + + return { + goTo, + goBack, + replace, + ROUTES, + }; +}; \ No newline at end of file diff --git a/src/shared/lib/hooks/useQueryParam.ts b/src/shared/lib/hooks/useQueryParam.ts new file mode 100644 index 0000000..7f9cf2f --- /dev/null +++ b/src/shared/lib/hooks/useQueryParam.ts @@ -0,0 +1,78 @@ +import { useMemo } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; + +export interface IUseQueryParam { + url: string, + queryParams: Record, + updateQueryParams: (newParams: Record, merge?: boolean) => void +} + +export interface IUseQueryParamOptions { + //Если `true`, будет производиться мерж новых параметров с существующими. + //Если `false`, новые параметры заменят существующие. + //По умолчанию: `true` + merge?: boolean; +} + +// Хук для работы с query параметрами в URL. +// @param {Record} [params] Объект с новыми query параметрами, которые необходимо добавить или заменить. Если не передан, возвращает текущий URL и параметры. +// @param {IUseQueryParamOptions} [options] Дополнительные опции. +// @returns {object} Объект с текущим URL, параметрами и функцией для обновления URL. +// - `url`: Текущий URL. +// - `queryParams`: Объект с текущими query параметрами. +// - `updateQueryParams`: Функция для обновления query параметров. +export const useQueryParam = ( + params?: Record, + options: IUseQueryParamOptions = { merge: true } +) => { + const [ searchParams ] = useSearchParams(); + const location = useLocation(); + // const navigate = useNavigate(); + + const queryParams = useMemo(() => { + const params: Record = {}; + searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; + }, [searchParams]); + + const url = (params && params.currenntUrl) ? params.currenntUrl : (location.pathname + location.search); + + const updateQueryParams = (newParams: Record, merge: boolean = options.merge ?? true) => { + const mergedParams = merge ? { ...queryParams, ...newParams } : newParams; + + // Удаление параметров со значением undefined, null или пустой строкой + const filteredParams: Record = Object.entries(mergedParams) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .reduce((acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, {} as Record); + + const newSearchParams = new URLSearchParams(filteredParams); + const newQueryString = newSearchParams.toString(); + + // Сохраняем pathname, чтобы не редиректить на / (если location.pathname отсутствует) + const newUrl = (params && params.currenntUrl) + ? params.currenntUrl + (newQueryString ? `?${newQueryString}` : '') + : location.pathname + (newQueryString ? `?${newQueryString}` : ''); + + return newUrl + // navigate(newUrl, { replace: true }); // Используем replace: true, чтобы не добавлять новую запись в историю браузера + }; + + + if (params) { + // Если параметры переданы, обновляем URL + useMemo(() => { + updateQueryParams(params, options.merge); + }, [params, options.merge, updateQueryParams]); + } + + return { + url, + queryParams, + updateQueryParams, + }; +}; \ No newline at end of file diff --git a/src/shared/lib/hooks/useScrollPage.ts b/src/shared/lib/hooks/useScrollPage.ts new file mode 100644 index 0000000..d00093c --- /dev/null +++ b/src/shared/lib/hooks/useScrollPage.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from 'react'; + +type UseScrollLockType = { + isScrollLocked: boolean; + disableScrollPage: () => void; + enableScrollPage: () => void; +}; + +export const useScrollPage = (initialLock = false): UseScrollLockType => { + const [isScrollLocked, setIsScrollLocked] = useState(initialLock); + + useEffect(() => { + document.body.style.overflow = isScrollLocked ? 'hidden' : 'auto'; + return () => { + document.body.style.overflow = 'auto'; + }; + }, [isScrollLocked]); + + const disableScrollPage = () => { + setIsScrollLocked(true); + }; + + const enableScrollPage = () => { + setIsScrollLocked(false); + }; + + return { isScrollLocked, disableScrollPage, enableScrollPage }; +}; \ No newline at end of file diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts new file mode 100644 index 0000000..1f9199c --- /dev/null +++ b/src/shared/lib/index.ts @@ -0,0 +1,5 @@ +export { classNames, type Mods } from './classnames/classNames'; +export { useDevices } from './hooks/useDevices'; +export { useScrollPage } from './hooks/useScrollPage'; +export { useClickOutside } from './hooks/useClickOutside'; +export { useQueryParam, type IUseQueryParamOptions, type IUseQueryParam } from './hooks/useQueryParam'; diff --git a/src/shared/lib/supabase/supabase.ts b/src/shared/lib/supabase/supabase.ts new file mode 100644 index 0000000..b10b133 --- /dev/null +++ b/src/shared/lib/supabase/supabase.ts @@ -0,0 +1,9 @@ +// import { createClient } from '@supabase/supabase-js'; +// +// import { VITE_SUPABASE_ANON_KEY, VITE_SUPABASE_URL } from "@/shared/api"; +// +// if (!VITE_SUPABASE_URL || !VITE_SUPABASE_ANON_KEY) { +// throw new Error('Missing Supabase environment variables'); +// } + +// export const supabase = createClient(VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY); diff --git a/src/shared/model/context/AuthContext.tsx b/src/shared/model/context/AuthContext.tsx new file mode 100644 index 0000000..1b97c3c --- /dev/null +++ b/src/shared/model/context/AuthContext.tsx @@ -0,0 +1,112 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { useNavigate } from "react-router-dom"; + +import { authApi, IUserSchema } from "@/shared/api"; +import { ROUTES } from "@/shared/lib/hooks/useNavigation"; + + +interface IAuthContext { + user: IUserSchema | null; + loading: boolean; + updateUserContext?: (newValues: Partial) => void; +} + +const AuthContext = createContext({ + user: null, + loading: true, +}); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + const validateToken = async () => { + const accessToken = window.localStorage.getItem("accessToken"); + const refreshToken = window.localStorage.getItem("refreshToken"); + + if (!accessToken) { + // Токена нет → отправляем на регистрацию + navigate(ROUTES.REGISTER, { replace: true }); + setLoading(false); + return; + } + + try { + const { data } = await authApi.validateToken(); + + if (data.success) { + // Токен валиден + setUser(data.user); + } else if (refreshToken) { + // Токен устарел → обновляем + const { data: refreshData } = await authApi.refreshToken({ refreshToken }); + if (refreshData.accessToken) { + window.localStorage.setItem("accessToken", refreshData.accessToken); + // Повторная валидация после обновления токена + const { data: validated } = await authApi.validateToken(); + if (validated.success) setUser(validated.user); + } else { + // Не удалось обновить → отправляем на логин + navigate(ROUTES.LOGIN, { replace: true }); + } + } else { + // Нет refresh токена → отправляем на логин + navigate(ROUTES.LOGIN, { replace: true }); + } + } catch (err) { + /* eslint-disable */ + console.error('Ошибка при проверке токена', err); + /* eslint-enable */ + navigate(ROUTES.LOGIN, { replace: true }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + validateToken() + // supabase.auth.getSession().then(({ data: { session } }) => { + // setSession(session); + // setUser(session?.user ?? null); + // setLoading(false); + // }); + // console.log('AuthProvider') + // const { data: { subscription } } = supabase.auth.onAuthStateChange( + // (_event, session) => { + // (async () => { + // setSession(session); + // setUser(session?.user ?? null); + // setLoading(false); + // })(); + // } + // ); + // + // return () => subscription.unsubscribe(); + }, []); + + const updateUserContext = (newValues: Partial) => { + if (newValues.user !== undefined) { + setUser(newValues.user); + } + }; + + const contextValue: IAuthContext = { + user, + loading, + updateUserContext + }; + return ( + + {children} + + ); +} diff --git a/src/shared/model/context/SuppliersSearchContext.tsx b/src/shared/model/context/SuppliersSearchContext.tsx new file mode 100644 index 0000000..16d0bb3 --- /dev/null +++ b/src/shared/model/context/SuppliersSearchContext.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState } from 'react'; + +import { ISupplier } from "@/shared/api"; + +interface ISuppliersSearchContext { + requestText: string; + tzText: string; + suppliers: ISupplier[]; + requestId?: string | number | null; + updateSuppliersSearchContext?: (newValues: Partial) => void; +} + +const SuppliersSearchContext = createContext({ + requestText: undefined, + tzText: undefined, + suppliers: [], + requestId: null, +}); + +export const useSuppliersSearch = () => { + const context = useContext(SuppliersSearchContext); + if (!context) { + throw new Error('useSuppliersSearch must be used within AuthProvider'); + } + return context; +}; + +export function SuppliersSearchProvider({ children }: { children: React.ReactNode }) { + const [requestText, setRequestText] = useState(''); + const [tzText, setTzText] = useState(''); + const [suppliers, setSuppliers] = useState([]); + const [requestId, setRequestId] = useState(null); + + const updateSuppliersSearchContext = (newValues: Partial) => { + if (newValues.requestText !== undefined) { + setRequestText(newValues.requestText); + } + if (newValues.tzText !== undefined) { + setTzText(newValues.tzText); + } + if (newValues.suppliers !== undefined) { + setSuppliers(newValues.suppliers); + } + if (newValues.requestId !== undefined) { + setRequestId(newValues.requestId); + } + }; + const contextValue: ISuppliersSearchContext = { + requestText, + tzText, + suppliers, + requestId, + updateSuppliersSearchContext, // функция обновления контекста + }; + + return ( + + {children} + + ); +} diff --git a/src/shared/model/index.ts b/src/shared/model/index.ts new file mode 100644 index 0000000..fe07f33 --- /dev/null +++ b/src/shared/model/index.ts @@ -0,0 +1,4 @@ +export { useSuppliersSearch, SuppliersSearchProvider } from './context/SuppliersSearchContext'; +export { useAuth, AuthProvider } from './context/AuthContext'; + +export { ROUTES, type RouteType, type RouteName, getRoute, generatePath } from './routes' diff --git a/src/shared/model/routes/index.ts b/src/shared/model/routes/index.ts new file mode 100644 index 0000000..6eb6671 --- /dev/null +++ b/src/shared/model/routes/index.ts @@ -0,0 +1,97 @@ +import React from "react"; + +export type RouteType = { + name: string; + path: string; + component: React.ComponentType; + exact?: boolean; + isProtected?: boolean; + isAuthOnly?: boolean; + layout?: React.ComponentType; + children?: RouteType[]; +}; + +export const ROUTES = { + HOME: '/', + LOGIN: '/login', + REGISTER: '/register', + RESET_PASSWORD: '/reset-password', + SUPPLIERS_SEARCH: '/suppliers-search', + MAILINGS: '/mailings', + SUPPLIERS: '/suppliers', + VIEWING_REQUESTS: '/viewing-requests', +} as const; + +export type RouteName = keyof typeof ROUTES; + +// export const routesConfig: RouteType[] = [ +// // Публичные роуты (не требуют аутентификации) +// { +// name: 'LOGIN', +// path: ROUTES.LOGIN, +// component: LoginPage, +// exact: true, +// isProtected: false, +// }, +// { +// name: 'REGISTER', +// path: ROUTES.REGISTER, +// component: RegisterPage, +// exact: true, +// isProtected: false, +// }, +// { +// name: 'RESET_PASSWORD', +// path: ROUTES.RESET_PASSWORD, +// component: ResetPasswordPage, +// exact: true, +// isProtected: false, +// }, +// +// // Защищенные роуты (требуют аутентификации) +// { +// name: 'HOME', +// path: ROUTES.HOME, +// component: HomePage, +// exact: true, +// isProtected: true, +// }, +// { +// name: 'SUPPLIERS_SEARCH', +// path: ROUTES.SUPPLIERS_SEARCH, +// component: SuppliersSearchPage, +// exact: true, +// isProtected: true, +// }, +// { +// name: 'MAILINGS', +// path: ROUTES.MAILINGS, +// component: MailingsPage, +// exact: true, +// isProtected: true, +// }, +// { +// name: 'SUPPLIERS', +// path: ROUTES.SUPPLIERS, +// component: SuppliersPage, +// exact: true, +// isProtected: true, +// }, +// ]; + +// Вспомогательные функции для работы с роутами +export const getRoute = (name: RouteName): string => ROUTES[name]; + +// export const getRouteConfig = (name: RouteName): RouteType | undefined => +// routesConfig.find(route => route.name === name); + +// Функция для генерации пути с параметрами +export const generatePath = (name: RouteName, params?: Record): string => { + let path = getRoute(name); + if (params) { + Object.entries(params).forEach(([key, value]) => { + path = path.replace(`:${key}`, String(value)); + }); + } + return path; +}; \ No newline at end of file diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts new file mode 100644 index 0000000..25aacaa --- /dev/null +++ b/src/shared/ui/index.ts @@ -0,0 +1,32 @@ +export { UIButton } from './ui-button/UIButton' +export { ButtonSize, ButtonVariant, ButtonType } from './ui-button/types' + + + +export { UICheckbox } from './ui-checkbox/UICheckbox' +export { UICheckboxList } from './ui-checkbox-list/UICheckboxList' + +export { UILoader } from './ui-loader/UILoader' +export { LoaderSize } from './ui-loader/types' + +export { UINotification } from './ui-notification/UINotification' +export { NotificationAppearance } from './ui-notification/types' + +export { UITextArea } from './ui-textarea/UITextArea' + +export { UITable } from './ui-table/UITable' +export { type Column, type UITableProps } from './ui-table/types' + +export { UIModalWrapper } from './ui-modal-wrapper/UIModalWrapper' +export { ModalSize, ModalType } from './ui-modal-wrapper/types' + +export { UIInput } from './ui-input/UIInput' +export { InputType } from './ui-input/types' + +export { UIStepper } from './ui-stepper/UIStepper' +export { type IStepperProps } from './ui-stepper/types' + +export { UIPhoneNumber } from './ui-phone-number/UIPhoneNumber' + + + diff --git a/src/shared/ui/styles/index.scss b/src/shared/ui/styles/index.scss new file mode 100644 index 0000000..b07f9b3 --- /dev/null +++ b/src/shared/ui/styles/index.scss @@ -0,0 +1,5 @@ +@forward "normalize"; + +* { + font-family: Inter, Arial, sans-serif; +} diff --git a/src/shared/ui/styles/mixins/colors.scss b/src/shared/ui/styles/mixins/colors.scss new file mode 100644 index 0000000..c96b796 --- /dev/null +++ b/src/shared/ui/styles/mixins/colors.scss @@ -0,0 +1,44 @@ +// основной фон страницы +$page-bg: #000; + +// фоны внутри страницы +$bg-hover: #333; +$bg-disabled: #cdcdcd; +$bg-active: #484848; +$bg-layout: #232323; +$bg-danger: #f00; +$bg-success: #0ec50f; +$bg-warning: #EAB114B2; +$bg-info: #12cad1; +$bg-danger-trp: rgba(255 0 0 / 90%); +$bg-success-trp: rgba(14 197 15 / 90%); +$bg-warning-trp: rgba(234 177 20 / 90%); +$bg-info-trp: rgb(18 202 209 / 90%); +$bg-default: #000; +$bg-light: #fff; +$bg-secondary: #5f6368; + +// основной цвет текста на странице +$font-color: #efefef; // для темного фона +$font-color-inverted: #010101; // для светлого фона +$font-color-secondary: #545454; // затемненный цвет текста + +// фон блоков страницы +$bg-main: #010101; // для светлого фона +$bg-main-inverted: #efefef; // для темного фона + +// Цвета для модалок +$modal-bg: #efefef; // фон +$modal-font-color: #010101; // цвет текста + +// Градиент скелетона +$skeleton-bg-main: #e2e5e7; +$skeleton-bg: rgb(176 176 176 / 60%); +$skeleton-bg-img: linear-gradient(90deg, $skeleton-bg-main, $skeleton-bg 80px, $skeleton-bg-main); + +// Оверлэй +$overlay-bg: rgb(0 0 0 / 50%); + +// Borders +$border-main: #545454; +$border-main-light: rgb(84 84 84 / 70%); diff --git a/src/shared/ui/styles/mixins/fonts-mixins.scss b/src/shared/ui/styles/mixins/fonts-mixins.scss new file mode 100644 index 0000000..89eef70 --- /dev/null +++ b/src/shared/ui/styles/mixins/fonts-mixins.scss @@ -0,0 +1,70 @@ +// Миксин для вычисления line-height +@mixin calcLineHeight($val: 16px, $multiplier: 1.5) { + line-height: calc(#{$val} * #{$multiplier}); +} + +// Миксины для заголовков +@mixin headerRegularBold() { + font-size: 24px; + font-weight: 900; + + @include calcLineHeight(24px); +} + +@mixin headerRegularSemiBold() { + font-size: 24px; + font-weight: 700; + + @include calcLineHeight(24px); +} + +@mixin headerRegular() { + font-size: 24px; + font-weight: 400; + + @include calcLineHeight(24px); +} + +// Миксины для текста +@mixin textRegularBold() { + font-size: 16px; + font-weight: 900; + + @include calcLineHeight(); +} + +@mixin textRegularSemiBold() { + font-size: 16px; + font-weight: 700; + + @include calcLineHeight(); +} + +@mixin textRegular() { + font-size: 16px; + font-weight: 400; + + @include calcLineHeight(); +} + +// Миксины для подписи +@mixin signatureRegularBold() { + font-size: 10px; + font-weight: 900; + + @include calcLineHeight(10px); +} + +@mixin signatureRegularSemiBold() { + font-size: 10px; + font-weight: 700; + + @include calcLineHeight(10px); +} + +@mixin signatureRegular() { + font-size: 10px; + font-weight: 400; + + @include calcLineHeight(10px); +} \ No newline at end of file diff --git a/src/shared/ui/styles/mixins/index.scss b/src/shared/ui/styles/mixins/index.scss new file mode 100644 index 0000000..9a3c86f --- /dev/null +++ b/src/shared/ui/styles/mixins/index.scss @@ -0,0 +1,8 @@ +@forward "colors"; +@forward "spin"; +@forward "skeletons"; +@forward "screens"; +@forward "fonts-mixins"; +@forward "text-ellipsis"; +@forward "overlay"; + diff --git a/src/shared/ui/styles/mixins/overlay.scss b/src/shared/ui/styles/mixins/overlay.scss new file mode 100644 index 0000000..f01e661 --- /dev/null +++ b/src/shared/ui/styles/mixins/overlay.scss @@ -0,0 +1,7 @@ +@use "colors"; + +@mixin overlay() { + position: fixed; + inset: 0; + background-color: colors.$overlay-bg +} \ No newline at end of file diff --git a/src/shared/ui/styles/mixins/screens.scss b/src/shared/ui/styles/mixins/screens.scss new file mode 100644 index 0000000..bada81b --- /dev/null +++ b/src/shared/ui/styles/mixins/screens.scss @@ -0,0 +1,17 @@ +@mixin isDesktopScreen { + @media only screen and (width >= 1024px) { + @content; + } +} + +@mixin isTabletScreen { + @media only screen and (width <= 1024px) { + @content; + } +} + +@mixin isMobileScreen { + @media only screen and (width <= 768px) { + @content; + } +} \ No newline at end of file diff --git a/src/shared/ui/styles/mixins/skeletons.scss b/src/shared/ui/styles/mixins/skeletons.scss new file mode 100644 index 0000000..4445c7b --- /dev/null +++ b/src/shared/ui/styles/mixins/skeletons.scss @@ -0,0 +1,18 @@ +@use "colors"; + +@mixin skeletonLoader { + background-color: colors.$skeleton-bg-main; + background-repeat: no-repeat; + background-image: colors.$skeleton-bg-img; + animation: shine 1.5s ease infinite; + + @keyframes shine { + 0% { + background-position: -280px; + } + + 100% { + background-position: 280px; + } + } +} diff --git a/src/shared/ui/styles/mixins/spin.scss b/src/shared/ui/styles/mixins/spin.scss new file mode 100644 index 0000000..f65093c --- /dev/null +++ b/src/shared/ui/styles/mixins/spin.scss @@ -0,0 +1,14 @@ +@mixin spin { + display: flex; + justify-content: center; + align-items: center; + border: 2px solid transparent; + border-top: 2px solid #fff; + border-radius: 50%; + animation: spin 1.5s linear infinite; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +} \ No newline at end of file diff --git a/src/shared/ui/styles/mixins/text-ellipsis.scss b/src/shared/ui/styles/mixins/text-ellipsis.scss new file mode 100644 index 0000000..969d972 --- /dev/null +++ b/src/shared/ui/styles/mixins/text-ellipsis.scss @@ -0,0 +1,5 @@ +@mixin textEllipsis() { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} \ No newline at end of file diff --git a/src/shared/ui/styles/normalize.scss b/src/shared/ui/styles/normalize.scss new file mode 100644 index 0000000..0727ec0 --- /dev/null +++ b/src/shared/ui/styles/normalize.scss @@ -0,0 +1,384 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + //scrollbar-color: #aeaeae #f1f1f1; + //scrollbar-width: thin; +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; + + &::-webkit-scrollbar { + width: 10px; + } + + &::-webkit-scrollbar-track { + background: #e8e8e8; + } + + &::-webkit-scrollbar-thumb { + background-color: #7a7a7a; + border-radius: 10px; + border: 2px solid #e8e8e8; + } +} + +/** + * Render the `places` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1, h2, h3, h4, h5, h6 { + margin: 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; + color: inherit; + text-decoration: none; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +button { + padding: 0; + border: none; + box-sizing: border-box; + background: transparent; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} + + +p { + margin: 0; +} + +ul, li { + list-style: none; + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/src/shared/ui/ui-button/UIButton.tsx b/src/shared/ui/ui-button/UIButton.tsx new file mode 100644 index 0000000..f77c3aa --- /dev/null +++ b/src/shared/ui/ui-button/UIButton.tsx @@ -0,0 +1,131 @@ +import { memo, ReactNode } from "react"; + +import { classNames, Mods } from "../../lib"; + +import { ButtonSize, ButtonType, ButtonVariant } from "./types"; + +interface IProps { + children?: ReactNode; + className?: string; + size?: ButtonSize; + variant?: ButtonVariant; + disabled?: boolean; + type?: ButtonType; + onClick?: () => void; + fullWidth?: boolean; + loading?: boolean; + active?: boolean; + icon?: React.ComponentType<{ className?: string }>; + iconPosition?: "left" | "right"; + alignText?: "left" | "center" | "right"; +} + +export const UIButton = memo((props: IProps) => { + const { + children, + size = ButtonSize.Md, + variant = ButtonVariant.Default, + type = ButtonType.Button, + className = "", + disabled = false, + fullWidth = false, + loading = false, + active = false, + icon: Icon, + iconPosition = "left", + alignText = "left", + onClick, + } = props; + + + const alignClasses = { + left: "justify-start text-left", + center: "justify-center text-center", + right: "justify-end text-right", + }; + + const sizeClasses = { + [ButtonSize.Sm]: "text-sm px-3 py-1.5", + [ButtonSize.Md]: "text-sm px-4 py-2.5", + [ButtonSize.Lg]: "text-base px-6 py-3", + }; + + const variantClasses = { + [ButtonVariant.Default]: active + ? "bg-blue-50 text-blue-700" + : "bg-blue-600 hover:bg-blue-700 text-white", + [ButtonVariant.Border]: active + ? "bg-blue-50 text-blue-700 border border-blue-200" + : "border border-gray-300 hover:border-gray-400 text-gray-700 hover:bg-gray-50", + [ButtonVariant.Transparent]: active + ? "text-blue-600 bg-blue-50" + : "text-gray-700 hover:bg-gray-100", + [ButtonVariant.Text]: active + ? "text-blue-600 font-medium" + : "text-blue-600 hover:text-blue-700 font-medium", + [ButtonVariant.Danger]: "text-red-600 hover:bg-red-50", + [ButtonVariant.Success]: active + ? "bg-green-50 text-blue-700" + : "bg-green-700 hover:bg-green-700 text-white", + }; + + const baseClasses = + "flex items-center rounded-lg transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 cursor-pointer"; + const stateClasses = disabled || loading + ? "opacity-50 cursor-not-allowed" + : "cursor-pointer"; + + const widthClass = fullWidth ? "w-full" : ""; + + const additionalClasses = [ + stateClasses, + widthClass, + sizeClasses[size], + variantClasses[variant], + alignClasses[alignText], + className, + ].filter(Boolean); + + + const stringClasses: string[] = []; + const mods: Mods = {}; + + additionalClasses.forEach(cls => { + if (typeof cls === 'string') { + stringClasses.push(cls); + } else if (typeof cls === 'object' && cls !== null) { + Object.assign(mods, cls); + } + }); + + const btnClass = classNames(baseClasses, mods, stringClasses); + + const content = ( + <> + {Icon && iconPosition === "left" && ( + + )} + {loading ? ( + + {children} + + ) : ( + children + )} + {Icon && iconPosition === "right" && ( + + )} + + ); + + return ( + + ); +}); \ No newline at end of file diff --git a/src/shared/ui/ui-button/types.ts b/src/shared/ui/ui-button/types.ts new file mode 100644 index 0000000..d505736 --- /dev/null +++ b/src/shared/ui/ui-button/types.ts @@ -0,0 +1,20 @@ +export const enum ButtonSize { + Sm = 'Sm', + Md = 'Md', + Lg = 'Lg', +} + +export const enum ButtonVariant { + Default = 'Default', + Border = 'Border', + Transparent = 'Transparent', + Text = 'Text', + Danger = 'Danger', + Success = 'Success', +} + +export const enum ButtonType { + Submit = 'submit', + Reset = 'reset', + Button = 'button', +} \ No newline at end of file diff --git a/src/shared/ui/ui-checkbox-list/UICheckboxList.tsx b/src/shared/ui/ui-checkbox-list/UICheckboxList.tsx new file mode 100644 index 0000000..75a9ce0 --- /dev/null +++ b/src/shared/ui/ui-checkbox-list/UICheckboxList.tsx @@ -0,0 +1,190 @@ +import React, { useState, useCallback, useRef } from 'react'; + +import { UICheckbox } from "@/shared/ui"; + + +export interface ICheckboxItem { + id: string | number; + title: string; + description?: string; +} + +export interface UICheckboxListProps { + /** + * Список компаний + */ + items: ICheckboxItem[]; + + /** + * Обработчик изменения выбранных компаний + */ + onChange: (selectedCompanyIds: (string | number)[]) => void; + + /** + * Высота контейнера списка + */ + height?: number; + + /** + * Дополнительные CSS классы + */ + className?: string; + + /** + * ID для тестирования + */ + 'data-testid'?: string; + + /** + * Изначально выбранные компании + */ + initialSelectedIds?: (string | number)[]; +} + +/** + * Компонент списка компаний с чекбоксами и функцией "Выбрать все" + */ +export const UICheckboxList = (props: UICheckboxListProps) => { + const { + items, + onChange, + height = 500, + className = '', + 'data-testid': dataTestId = 'ui-checkbox-list', + initialSelectedIds = [] + } = props; + + // Состояние выбранных компаний + const [selectedIds, setSelectedIds] = useState<(string | number)[]>(initialSelectedIds); + + // Используем ref для хранения предыдущего значения, чтобы избежать лишних вызовов onChange + const prevSelectedIdsRef = useRef<(string | number)[]>(initialSelectedIds); + + // Вычисляем, выбраны ли все компании + const allSelected = items.length > 0 && selectedIds.length === items.length; + + // Вычисляем, выбраны ли некоторые (но не все) компании + const someSelected = selectedIds.length > 0 && selectedIds.length < items.length; + + // Обработчик выбора/снятия отдельной компании + const handleCompanyToggle = useCallback((companyId: string | number, isChecked: boolean) => { + setSelectedIds(prev => { + let newSelectedIds; + + if (isChecked) { + // Добавляем компанию, если её нет в списке + newSelectedIds = prev.includes(companyId) ? prev : [...prev, companyId]; + } else { + // Удаляем компанию из списка + newSelectedIds = prev.filter(id => id !== companyId); + } + + // Вызываем onChange только если значение действительно изменилось + if (JSON.stringify(newSelectedIds) !== JSON.stringify(prevSelectedIdsRef.current)) { + prevSelectedIdsRef.current = [...newSelectedIds]; + onChange(newSelectedIds); + } + + return newSelectedIds; + }); + }, [onChange]); + + // Обработчик "Выбрать все"/"Снять все" + const handleSelectAllToggle = useCallback((isChecked: boolean) => { + setSelectedIds(() => { + let newSelectedIds: (string | number)[]; + + if (isChecked) { + // Выбираем все компании + newSelectedIds = items.map(company => company.id); + } else { + // Снимаем все компании + newSelectedIds = []; + } + + // Вызываем onChange только если значение действительно изменилось + if (JSON.stringify(newSelectedIds) !== JSON.stringify(prevSelectedIdsRef.current)) { + prevSelectedIdsRef.current = [...newSelectedIds]; + onChange(newSelectedIds); + } + + return newSelectedIds; + }); + }, [items, onChange]); + + // Сбрасываем выбор при изменении списка компаний (если нужно) + // Комментируем или удаляем этот эффект, если не нужно сбрасывать выбор при изменении items + // useEffect(() => { + // setSelectedIds([]); + // prevSelectedIdsRef.current = []; + // onChange([]); + // }, [items, onChange]); + + return ( +
+ {/* Блок "Выбрать все" */} +
+ +
+ + {/* Контейнер списка компаний с фиксированной высотой */} +
+
+ {items.length === 0 ? ( +
+ Нет компаний для выбора +
+ ) : ( +
+ {items.map((item) => ( +
+ handleCompanyToggle(item.id, checked)} + className="flex-1" + data-testid={`${dataTestId}-checkbox-${item.id}`} + /> +
+ ))} +
+ )} +
+
+ + {/* Информация о выбранных элементах */} + {items.length > 0 && ( +
+ Выбрано: {selectedIds.length} из {items.length} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/shared/ui/ui-checkbox/UICheckbox.tsx b/src/shared/ui/ui-checkbox/UICheckbox.tsx new file mode 100644 index 0000000..875f1ce --- /dev/null +++ b/src/shared/ui/ui-checkbox/UICheckbox.tsx @@ -0,0 +1,166 @@ +import { Check } from 'lucide-react'; +import React from 'react'; + +import { classNames } from "@/shared/lib"; + +export interface UICheckboxProps { + /** + * Текст метки чекбокса + */ + label?: string; + + /** + * описание метки чекбокса + */ + description?: string; + + /** + * Значение чекбокса (выбран/не выбран) + */ + value: boolean; + + /** + * Обработчик изменения состояния + */ + onChange: (value: boolean) => void; + + /** + * Дополнительные CSS классы для контейнера + */ + className?: string; + + /** + * Отключение чекбокса + */ + disabled?: boolean; + + /** + * Позиционирование метки относительно чекбокса + */ + labelPosition?: 'left' | 'right'; + + /** + * ID для тестирования + */ + 'data-testid'?: string; +} + +/** + * Компонент чекбокса с меткой + */ +export const UICheckbox = (props: UICheckboxProps) => { + const { + label, + description, + value, + onChange, + className = '', + disabled = false, + labelPosition = 'right', + 'data-testid': dataTestId = 'ui-checkbox', + } = props; + + const handleChange = (event: React.ChangeEvent) => { + if (!disabled) { + onChange(event.target.checked); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if ((event.key === 'Enter' || event.key === ' ') && !disabled) { + event.preventDefault(); + onChange(!value); + } + }; + + const checkboxContent = ( +
+ + +
!disabled && onChange(!value)} + data-testid={dataTestId} + > + {value && ( + + )} +
+
+ ); + + const labelContent = label && ( +
+ !disabled && onChange(!value)} + > + {label} + + + {description && ( + !disabled && onChange(!value)} + > + {description} + )} +
+ + ); + + return ( +
+ {labelPosition === 'left' && labelContent} + {checkboxContent} + {labelPosition === 'right' && labelContent} +
+ ); +}; \ No newline at end of file diff --git a/src/shared/ui/ui-input/UIInput.tsx b/src/shared/ui/ui-input/UIInput.tsx new file mode 100644 index 0000000..b49e284 --- /dev/null +++ b/src/shared/ui/ui-input/UIInput.tsx @@ -0,0 +1,95 @@ +import { ChangeEvent, memo, useCallback, useMemo, useState } from 'react'; + +import { classNames, Mods } from "@/shared/lib"; + +import { InputType } from "./types"; + +interface IProps { + value: string; + type?: InputType; + placeholder?: string; + minLength?: number; + maxLength?: number; + handleValueChange?: (val: string) => void; + required?: boolean; + disabled?: boolean; + className?: string; + id?: string; + label?: string; + pattern?: string; +} + +export const UIInput = memo((props: IProps) => { + const { + placeholder = '', + value, + type = InputType.Text, + minLength = 1, + maxLength = Infinity, + required = false, + disabled = false, + className = '', + id, + label, + pattern, + handleValueChange, + } = props; + + const [inputValue, setInputValue] = useState(value); + + const inputClass = useMemo(() => { + const mods: Mods = { + 'bg-gray-100 cursor-not-allowed': disabled, + 'focus:outline-none focus:ring-2 focus:ring-blue-500': !disabled, + }; + + return classNames( + 'border border-gray-300 rounded-md p-2 text-sm w-full transition-colors', + mods, + [className], + ); + }, [disabled, className]); + + const handleChangeValue = useCallback( + (e: ChangeEvent) => { + setInputValue(e.target.value); + handleValueChange?.(e.target.value); + }, + [handleValueChange], + ); + + const input = ( + + ); + + if (!label) { + return input; + } + + return ( +
+ + + {input} +
+ ); +}); diff --git a/src/shared/ui/ui-input/types.ts b/src/shared/ui/ui-input/types.ts new file mode 100644 index 0000000..14b2846 --- /dev/null +++ b/src/shared/ui/ui-input/types.ts @@ -0,0 +1,8 @@ +export const enum InputType { + Text = 'text', + Number = 'number', + Url = 'url', + Email = 'email', + Password = 'password', + PhoneNumber = 'tel', +} \ No newline at end of file diff --git a/src/shared/ui/ui-loader/UILoader.tsx b/src/shared/ui/ui-loader/UILoader.tsx new file mode 100644 index 0000000..a38a022 --- /dev/null +++ b/src/shared/ui/ui-loader/UILoader.tsx @@ -0,0 +1,83 @@ +import { memo } from "react"; + +import { classNames, Mods } from "../../lib"; + +import { LoaderSize } from "./types"; + +interface IProps { + size?: LoaderSize; + text?: string; + className?: string; + fullScreen?: boolean; +} + +export const UILoader = memo((props: IProps) => { + const { + size = LoaderSize.Md, + text, + className = "", + fullScreen = false, + } = props; + + // Размеры лоадера + const sizeClasses = { + [LoaderSize.Sm]: "h-6 w-6 border-b-2", + [LoaderSize.Md]: "h-8 w-8 border-b-2", + [LoaderSize.Lg]: "h-12 w-12 border-b-2", + [LoaderSize.Xl]: "h-16 w-16 border-b-4", + }; + + // Размеры текста + const textSizeClasses = { + [LoaderSize.Sm]: "text-sm mt-2", + [LoaderSize.Md]: "text-sm mt-3", + [LoaderSize.Lg]: "text-base mt-4", + [LoaderSize.Xl]: "text-lg mt-4", + }; + + // Цвета + const loaderColor = "border-blue-600"; + const textColor = "text-gray-600"; + + const mods: Mods = {}; + + const wrapperClasses = [ + "text-center", + fullScreen ? "py-24" : "py-12", + className + ].filter(Boolean) as string[]; + + const wrapperClass = classNames( + "text-center", + mods, + wrapperClasses + ); + + const loaderClass = classNames( + "inline-block animate-spin rounded-full", + mods, + [ + sizeClasses[size], + loaderColor + ] + ); + + const textClass = classNames( + textColor, + mods, + [ + textSizeClasses[size] + ] + ); + + return ( +
+
+ {text && ( +

+ {text} +

+ )} +
+ ); +}); \ No newline at end of file diff --git a/src/shared/ui/ui-loader/types.ts b/src/shared/ui/ui-loader/types.ts new file mode 100644 index 0000000..b931eab --- /dev/null +++ b/src/shared/ui/ui-loader/types.ts @@ -0,0 +1,6 @@ +export const enum LoaderSize { + Sm = 'Sm', + Md = 'Md', + Lg = 'Lg', + Xl = 'Xl', +} \ No newline at end of file diff --git a/src/shared/ui/ui-modal-wrapper/UIModalWrapper.tsx b/src/shared/ui/ui-modal-wrapper/UIModalWrapper.tsx new file mode 100644 index 0000000..648d38b --- /dev/null +++ b/src/shared/ui/ui-modal-wrapper/UIModalWrapper.tsx @@ -0,0 +1,103 @@ +import React, { memo, ReactNode, useEffect, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; + +import { classNames, Mods } from "@/shared/lib"; +import { UILoader } from "@/shared/ui"; +import { ModalSize, ModalType } from "@/shared/ui/ui-modal-wrapper/types"; + +interface IProps { + type?: ModalType; + size?: ModalSize; + children: ReactNode | null; + className?: string; + onClose: () => void; + showBlur?: boolean; + icon?: React.ComponentType<{ className?: string }>; +} + +export const UIModalWrapper = memo((props: IProps) => { + const { + type = ModalType.Default, + size = ModalSize.Md, + children = null, + onClose = () => {}, + showBlur = false, + className = '', + icon: Icon, + } = props; + + const modalRef = useRef(null); + + const handleClick = (event: React.MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const keyPress = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + useEffect(() => { + document.addEventListener('keydown', keyPress); + return () => { + document.removeEventListener('keydown', keyPress); + }; + }, [onClose]); + + const modalClasses: string = useMemo(() => { + let sizeClass = ''; + switch (size) { + case ModalSize.Sm: + sizeClass = 'w-80'; + break; + case ModalSize.Md: + sizeClass = 'w-1/3'; + break; + case ModalSize.Lg: + sizeClass = 'w-2/3'; + break; + case ModalSize.Full: + sizeClass = 'w-full h-full'; + break; + } + + + const mods: Mods = { + type: type === ModalType.Image ? 'modal-image' : 'modal', + size: sizeClass, + }; + + + return classNames('bg-white rounded-lg shadow-lg relative', mods, [className]); + }, [type, size, className]); + + return ( + <> + {createPortal( +
+
+ {Icon && ( +
+ +
+ )} + + {showBlur && ( +
e.stopPropagation()}> + +
+ )} + +
+ {children} +
+
+
, + document.body + )} + + ); +}); diff --git a/src/shared/ui/ui-modal-wrapper/types.ts b/src/shared/ui/ui-modal-wrapper/types.ts new file mode 100644 index 0000000..d995ce9 --- /dev/null +++ b/src/shared/ui/ui-modal-wrapper/types.ts @@ -0,0 +1,11 @@ +export const enum ModalType { + Default = 'default', + Image = 'image' +} + +export const enum ModalSize { + Sm = 'sm', + Md = 'md', + Lg = 'lg', + Full = 'full', +} \ No newline at end of file diff --git a/src/shared/ui/ui-notification/UINotification.tsx b/src/shared/ui/ui-notification/UINotification.tsx new file mode 100644 index 0000000..d17724c --- /dev/null +++ b/src/shared/ui/ui-notification/UINotification.tsx @@ -0,0 +1,80 @@ +import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from "lucide-react"; +import { memo } from "react"; + +import { classNames, Mods } from "../../lib"; + +import { NotificationAppearance } from "./types"; + +interface IProps { + text: string; + title?: string; + appearance?: NotificationAppearance; + className?: string; + onClose?: () => void; + closable?: boolean; +} + +export const UINotification = memo((props: IProps) => { + const { + text, + title, + appearance = NotificationAppearance.Info, + className = "", + onClose, + closable = false, + } = props; + + // Иконки для разных типов + const appearanceIcons = { + [NotificationAppearance.Danger]: AlertCircle, + [NotificationAppearance.Info]: Info, + [NotificationAppearance.Warning]: AlertTriangle, + [NotificationAppearance.Success]: CheckCircle, + }; + + const Icon = appearanceIcons[appearance]; + + const baseClasses = "border px-4 py-3 rounded-lg text-sm"; + + const mods: Mods = {}; + + // Дополнительные классы + const additionalClasses = [ + // Классы для разных вариантов нотификации + appearance === NotificationAppearance.Danger && "bg-red-50 border-red-200 text-red-700", + appearance === NotificationAppearance.Info && "bg-blue-50 border-blue-200 text-blue-700", + appearance === NotificationAppearance.Warning && "bg-yellow-50 border-yellow-200 text-yellow-700", + appearance === NotificationAppearance.Success && "bg-green-50 border-green-200 text-green-700", + className + ].filter(Boolean) as string[]; + + + const notificationClass = classNames( + baseClasses, + mods, + additionalClasses + ); + + return ( +
+
+ +
+ {title && ( +

{title}

+ )} +

{text}

+
+ {closable && ( + + )} +
+
+ ); +}); \ No newline at end of file diff --git a/src/shared/ui/ui-notification/types.ts b/src/shared/ui/ui-notification/types.ts new file mode 100644 index 0000000..a5e6792 --- /dev/null +++ b/src/shared/ui/ui-notification/types.ts @@ -0,0 +1,6 @@ +export const enum NotificationAppearance { + Danger = 'Danger', + Info = 'Info', + Warning = 'Warning', + Success = 'Success', +} \ No newline at end of file diff --git a/src/shared/ui/ui-phone-number/UIPhoneNumber.tsx b/src/shared/ui/ui-phone-number/UIPhoneNumber.tsx new file mode 100644 index 0000000..3ee0381 --- /dev/null +++ b/src/shared/ui/ui-phone-number/UIPhoneNumber.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, ChangeEvent, useMemo } from "react"; + +import { classNames, Mods } from "@/shared/lib"; +import { InputType } from "@/shared/ui"; + +interface IProps { + value?: string; + handleValueChange?: (val: string) => void; + label?: string; + required?: boolean; + id?: string; + className?: string; + disabled?: boolean; + type: InputType; +} + +export const UIPhoneNumber = (props: IProps) => { + const { value, handleValueChange, id, className, type, label, disabled, required } = props; + const inputRef = useRef(null); + + /* eslint-disable */ + const setCursorPosition = (pos: any, el: any) => { + el.focus(); + if (el.setSelectionRange) el.setSelectionRange(pos, pos); + else if (el.createTextRange) { + const range = el.createTextRange(); + range.collapse(true); + range.moveEnd("character", pos); + range.moveStart("character", pos); + range.select(); + } + }; + /* eslint-enable */ + + const mask = (e: ChangeEvent) => { + const matrix = e.target.placeholder; + let i = 0; + const def = matrix.replace(/\D/g, ""); + let val = e.target.value.replace(/\D/g, ""); + if (def.length >= val.length) val = def; + e.target.value = matrix.replace(/[_\d]/g, () => val.charAt(i++) || "_"); + i = e.target.value.lastIndexOf(val.substr(-1)); + /* eslint-disable */ + i < e.target.value.length ? i++ : (i = e.target.value.indexOf("_")); + /* eslint-enable */ + setCursorPosition(i, e.target); + handleValueChange(e.target.value); // Передаем значение в родителя + }; + + useEffect(() => { + const input = inputRef.current; + input.addEventListener("input", mask, false); + input.focus(); + setCursorPosition(3, input); + + return () => { + input.removeEventListener("input", mask, false); + }; + }, []); + + const inputClass = useMemo(() => { + const mods: Mods = { + 'bg-gray-100 cursor-not-allowed': disabled, + 'focus:outline-none focus:ring-2 focus:ring-blue-500': !disabled, + }; + + return classNames( + 'border border-gray-300 rounded-md p-2 text-sm w-full transition-colors', + mods, + [className], + ); + }, [disabled, className]); + + const input = ( + {}} // предотвращаем React warning + /> + ); + + if (!label) { + return input; + } + + return ( +
+ + + {input} +
+ ); +}; diff --git a/src/shared/ui/ui-phone-number/types.ts b/src/shared/ui/ui-phone-number/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/ui/ui-stepper/UIStepper.tsx b/src/shared/ui/ui-stepper/UIStepper.tsx new file mode 100644 index 0000000..b470e8a --- /dev/null +++ b/src/shared/ui/ui-stepper/UIStepper.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +import { classNames } from '@/shared/lib'; + +export interface StepItem { + text: string; + value: V; +} + +export interface StepperProps { + steps: StepItem[]; + currentStep: V; + onStepChange?: (step: V) => void; +} + +export const UIStepper = ( + props: StepperProps, +) => { + const { steps, currentStep, onStepChange } = props; + + const currentIndex = steps.findIndex( + step => step.value === currentStep, + ); + + return ( +
+ {steps.map((step, index) => { + const isActive = step.value === currentStep; + const isCompleted = index < currentIndex; + + return ( + + {/* Step */} + + + {/* Progress line */} + {index < steps.length - 1 && ( +
+
+
+ )} + + ); + })} +
+ ); +}; diff --git a/src/shared/ui/ui-stepper/types.ts b/src/shared/ui/ui-stepper/types.ts new file mode 100644 index 0000000..0091309 --- /dev/null +++ b/src/shared/ui/ui-stepper/types.ts @@ -0,0 +1,5 @@ +export interface IStepperProps { + steps: T[]; + currentStep: T; + onStepChange?: (step: T) => void; +} \ No newline at end of file diff --git a/src/shared/ui/ui-table/UITable.tsx b/src/shared/ui/ui-table/UITable.tsx new file mode 100644 index 0000000..91a4841 --- /dev/null +++ b/src/shared/ui/ui-table/UITable.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState, useMemo, useRef } from "react"; + +import { UICheckbox, UITableProps } from "@/shared/ui"; + +export const UITable = (props: UITableProps) => { + const { columns, data, checkable = false, onSelectionChange, maxWidth, maxHeight, fixedHeader } = props; + const [selectedIndexes, setSelectedIndexes] = useState([]); + + const allSelected = data.length > 0 && selectedIndexes.length === data.length; + + const selectedRows = useMemo(() => { + return selectedIndexes.map((i) => data[i]); + }, [selectedIndexes, data]); + + const prevSignatureRef = useRef(null); + + useEffect(() => { + if (!onSelectionChange) return; + + const signature = selectedRows + .map((row) => JSON.stringify(row)) + .join('|'); + + if (prevSignatureRef.current !== signature) { + prevSignatureRef.current = signature; + onSelectionChange(selectedRows); + } + }, [selectedRows, onSelectionChange]); + + + const toggleAll = () => { + setSelectedIndexes(allSelected ? [] : data.map((_, index) => index)); + }; + + const toggleOne = (index: number) => { + setSelectedIndexes((prev) => + prev.includes(index) + ? prev.filter((i) => i !== index) + : [...prev, index] + ); + }; + + return ( +
+ + + + {checkable && ( + + )} + + {columns.map((col) => ( + + ))} + + + + + {data.map((row, rowIndex) => ( + + {checkable && ( + + )} + {columns.map((col) => ( + + ))} + + ))} + +
+ toggleAll()} + className="h-4 w-4" + /> + + {col.title} +
+ toggleOne(rowIndex)} + className="h-4 w-4" + /> + + {String(row[col.key])} +
+
+ ); +}; diff --git a/src/shared/ui/ui-table/types.ts b/src/shared/ui/ui-table/types.ts new file mode 100644 index 0000000..73e1230 --- /dev/null +++ b/src/shared/ui/ui-table/types.ts @@ -0,0 +1,21 @@ +export type Column = { + key: keyof T; + title: string; + /** Максимальная ширина колонки */ + maxWidth?: number | string; +}; + +export type UITableProps = { + columns: Column[]; + data: T[]; + checkable?: boolean; + onSelectionChange?: (selectedRows: T[]) => void; + /** Максимальная ширина таблицы */ + maxWidth?: number | string; + + /** Максимальная высота таблицы */ + maxHeight?: number | string; + + /** Закреплять заголовок таблицы */ + fixedHeader?: boolean; +}; \ No newline at end of file diff --git a/src/shared/ui/ui-textarea/UITextArea.tsx b/src/shared/ui/ui-textarea/UITextArea.tsx new file mode 100644 index 0000000..f150394 --- /dev/null +++ b/src/shared/ui/ui-textarea/UITextArea.tsx @@ -0,0 +1,145 @@ +import { memo, TextareaHTMLAttributes, useEffect, useRef } from "react"; + +import { classNames, Mods } from "../../lib"; + +interface IProps extends Omit, 'onChange'> { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + label?: string; + minCount?: number; + maxCount?: number; + showCounter?: boolean; + rows?: number; + autoResize?: boolean; + maxHeight?: number +} + +export const UITextArea = memo((props: IProps) => { + const { + value, + onChange, + placeholder, + className = '', + label, + minCount = 0, + maxCount, + rows = 6, + showCounter, + autoResize = false, + maxHeight, + ...restProps + } = props; + + const textareaRef = useRef(null); + + const currentLength = value.length; + const isMinCountError = minCount > 0 && currentLength < minCount; + const isMaxCountError = maxCount && currentLength > maxCount; + const isCountError = isMinCountError || isMaxCountError; + + const handleChange = (e: React.ChangeEvent) => { + let newValue = e.target.value; + + if (maxCount && newValue.length > maxCount) { + newValue = newValue.slice(0, maxCount); + } + + onChange(newValue); + }; + + useEffect(() => { + if (!autoResize || !textareaRef.current) return; + + const textarea = textareaRef.current; + + textarea.style.height = 'auto'; + + const scrollHeight = textarea.scrollHeight; + + if (maxHeight && scrollHeight > maxHeight) { + textarea.style.height = `${maxHeight}px`; + textarea.style.overflowY = 'auto'; + } else { + textarea.style.height = `${scrollHeight}px`; + textarea.style.overflowY = 'hidden'; + } + }, [value, autoResize, maxHeight]); + + const textareaBaseClasses = + 'w-full px-4 py-3 border border-gray-300 rounded-lg ' + + 'focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none'; + + const textareaMods: Mods = { + 'resize-none': autoResize, + 'resize-y': !autoResize, + }; + + const textareaClass = classNames( + textareaBaseClasses, + textareaMods, + [className], + ); + + const counterClass = classNames( + 'absolute top-2 right-2 text-xs px-2 py-1 rounded', + {}, + [ + isCountError + ? 'bg-red-100 text-red-700' + : 'bg-gray-100 text-gray-500', + ], + ); + + return ( +
+ {(label || maxCount) && ( +
+ {label && ( + + )} + + {maxCount && ( +
+ {currentLength}/{maxCount} +
+ )} +
+ )} + +
+