├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.module.css ├── App.tsx ├── assets │ ├── aa.png │ ├── logo.png │ └── logo.svg ├── components │ ├── UI │ │ ├── AreaLoader │ │ │ ├── AreaLoader.module.css │ │ │ └── AreaLoader.tsx │ │ ├── Badge │ │ │ ├── Badge.module.css │ │ │ └── Badge.tsx │ │ ├── BaseLink │ │ │ ├── BaseLink.module.css │ │ │ └── BaseLink.tsx │ │ ├── Button │ │ │ ├── Button.module.css │ │ │ └── Button.tsx │ │ ├── Card │ │ │ ├── Card.module.css │ │ │ └── Card.tsx │ │ ├── Checkbox │ │ │ ├── Checkbox.module.css │ │ │ └── Checkbox.tsx │ │ ├── Chip │ │ │ ├── Chip.module.css │ │ │ └── Chip.tsx │ │ ├── Form │ │ │ ├── Form.module.css │ │ │ └── Form.tsx │ │ ├── IconButton │ │ │ ├── IconButton.module.css │ │ │ └── IconButton.tsx │ │ ├── Input │ │ │ ├── Input.module.css │ │ │ └── Input.tsx │ │ ├── LoadMore │ │ │ └── LoadMore.tsx │ │ ├── Loader │ │ │ ├── Loader.module.css │ │ │ └── Loader.tsx │ │ ├── Placeholder │ │ │ ├── Placeholder.module.css │ │ │ └── Placeholder.tsx │ │ ├── Select │ │ │ ├── Select.module.css │ │ │ └── Select.tsx │ │ ├── Spinner │ │ │ ├── Spinner.module.css │ │ │ └── Spinner.tsx │ │ ├── Textarea │ │ │ ├── Textarea.module.css │ │ │ └── Textarea.tsx │ │ ├── Toast │ │ │ ├── Toast.module.css │ │ │ └── Toast.tsx │ │ ├── Toggle │ │ │ ├── Toggle.module.css │ │ │ └── Toggle.tsx │ │ ├── Tooltip │ │ │ ├── Tooltip.module.css │ │ │ └── Tooltip.tsx │ │ └── icons │ │ │ ├── AddIcon │ │ │ ├── AddIcon.module.css │ │ │ └── AddIcon.tsx │ │ │ ├── ArrowLeftIcon │ │ │ ├── ArrowLeftIcon.module.css │ │ │ └── ArrowLeftIcon.tsx │ │ │ ├── BrandsIcon │ │ │ └── BrandsIcon.tsx │ │ │ ├── CartIcon │ │ │ ├── CartIcon.module.css │ │ │ └── CartIcon.tsx │ │ │ ├── CategoryIcon │ │ │ ├── CategoryIcon.module.css │ │ │ └── CategoryIcon.tsx │ │ │ ├── CheckIcon │ │ │ └── CheckIcon.tsx │ │ │ ├── CloseIcon │ │ │ ├── CloseIcon.module.css │ │ │ └── CloseIcon.tsx │ │ │ ├── DeliveryIcon │ │ │ └── DeliveryIcon.tsx │ │ │ ├── EditIcon │ │ │ ├── EditIcon.module.css │ │ │ └── EditIcon.tsx │ │ │ ├── FavoriteIcon │ │ │ ├── FavoriteIcon.module.css │ │ │ └── FavoriteIcon.tsx │ │ │ ├── LockIcon │ │ │ ├── LockIcon.module.css │ │ │ └── LockIcon.tsx │ │ │ ├── MenuIcon │ │ │ ├── MenuIcon.module.css │ │ │ └── MenuIcon.tsx │ │ │ ├── OrderIcon │ │ │ ├── OrderIcon.module.css │ │ │ └── OrderIcon.tsx │ │ │ ├── PriceIcon │ │ │ └── PriceIcon.tsx │ │ │ ├── ProductIcon │ │ │ ├── ProductIcon.module.css │ │ │ └── ProductIcon.tsx │ │ │ └── TrashIcon │ │ │ ├── TrashIcon.module.css │ │ │ └── TrashIcon.tsx │ ├── admin │ │ ├── Brands │ │ │ ├── Brands.module.css │ │ │ └── Brands.tsx │ │ ├── Categories │ │ │ ├── Categories.module.css │ │ │ └── Categories.tsx │ │ ├── Order │ │ │ ├── Order.module.css │ │ │ ├── Order.tsx │ │ │ └── OrderCartItem │ │ │ │ ├── OrderCartItem.module.css │ │ │ │ └── OrderCartItem.tsx │ │ ├── ProductForm │ │ │ ├── ProductForm.module.css │ │ │ ├── ProductForm.tsx │ │ │ └── ProductFormSelect │ │ │ │ ├── ProductFormSelect.module.css │ │ │ │ └── ProductFormSelect.tsx │ │ ├── ProductsList │ │ │ ├── ProductsList.module.css │ │ │ └── ProductsList.tsx │ │ ├── SettingsForm │ │ │ ├── SettingsForm.module.css │ │ │ └── SettingsForm.tsx │ │ └── SettingsList │ │ │ ├── SettingsList.module.css │ │ │ └── SettingsList.tsx │ ├── layouts │ │ ├── adminLayouts │ │ │ ├── Actions │ │ │ │ ├── Actions.module.css │ │ │ │ └── Actions.tsx │ │ │ ├── Content │ │ │ │ ├── Content.module.css │ │ │ │ └── Content.tsx │ │ │ ├── Header │ │ │ │ ├── Header.module.css │ │ │ │ └── Header.tsx │ │ │ ├── Main │ │ │ │ ├── Main.module.css │ │ │ │ └── Main.tsx │ │ │ └── Sidebar │ │ │ │ ├── Sidebar.module.css │ │ │ │ ├── Sidebar.tsx │ │ │ │ └── SidebarItem │ │ │ │ ├── SidebarItem.module.css │ │ │ │ └── SidebarItem.tsx │ │ └── showcaseLayouts │ │ │ ├── Section │ │ │ ├── Section.module.css │ │ │ ├── Section.tsx │ │ │ ├── SectionBody │ │ │ │ ├── SectionBody.module.css │ │ │ │ ├── SectionBody.tsx │ │ │ │ └── SectionBodyGrid │ │ │ │ │ ├── SectionBodyGrid.module.css │ │ │ │ │ └── SectionBodyGrid.tsx │ │ │ └── SectionHeader │ │ │ │ ├── SectionHeader.module.css │ │ │ │ └── SectionHeader.tsx │ │ │ ├── ShowcaseFooter │ │ │ ├── ShowcaseFooter.module.css │ │ │ └── ShowcaseFooter.tsx │ │ │ ├── ShowcaseHeader │ │ │ ├── ShowcaseHeader.module.css │ │ │ └── ShowcaseHeader.tsx │ │ │ └── ShowcaseMain │ │ │ ├── ShowcaseMain.module.css │ │ │ └── ShowcaseMain.tsx │ ├── pages │ │ ├── adminPages │ │ │ ├── AdminPage │ │ │ │ ├── AdminPage.module.css │ │ │ │ └── AdminPage.tsx │ │ │ ├── OrdersPage │ │ │ │ └── OrdersPage.tsx │ │ │ ├── ProductsPage │ │ │ │ ├── ProductsPage.module.css │ │ │ │ └── ProductsPage.tsx │ │ │ └── SettingsPage │ │ │ │ ├── SettingsPage.module.css │ │ │ │ └── SettingsPage.tsx │ │ └── showcasePages │ │ │ ├── CartPage │ │ │ ├── CartPage.module.css │ │ │ └── CartPage.tsx │ │ │ ├── CategoryPage │ │ │ ├── CategoryPage.module.css │ │ │ └── CategoryPage.tsx │ │ │ ├── CheckoutSuccessPage │ │ │ ├── CheckoutSuccessPage.module.css │ │ │ └── CheckoutSuccessPage.tsx │ │ │ ├── DiscountProductsPage │ │ │ └── DiscountProductsPage.tsx │ │ │ ├── NotFound │ │ │ ├── NotFound.module.css │ │ │ └── NotFound.tsx │ │ │ ├── ProductPage │ │ │ ├── InfoBlock │ │ │ │ ├── InfoBlock.module.css │ │ │ │ └── InfoBlock.tsx │ │ │ ├── ProductPage.module.css │ │ │ └── ProductPage.tsx │ │ │ ├── ShowcasePage │ │ │ ├── ShowcasePage.module.css │ │ │ └── ShowcasePage.tsx │ │ │ └── WishlistPage │ │ │ └── WishlistPage.tsx │ └── showcase │ │ ├── AddToCartBtn │ │ ├── AddToCartBtn.module.css │ │ └── AddToCartBtn.tsx │ │ ├── Cart │ │ ├── Cart.module.css │ │ ├── Cart.tsx │ │ ├── CartItem │ │ │ ├── CartItem.module.css │ │ │ └── CartItem.tsx │ │ └── CartSummary │ │ │ ├── CartSummary.module.css │ │ │ └── CartSummary.tsx │ │ ├── CartForm │ │ ├── CartForm.module.css │ │ └── CartForm.tsx │ │ ├── CategoriesList │ │ ├── CategoriesList.module.css │ │ └── CategoriesList.tsx │ │ ├── Filter │ │ ├── Filter.module.css │ │ └── Filter.tsx │ │ ├── Menu │ │ ├── Menu.module.css │ │ └── Menu.tsx │ │ ├── ProductCard │ │ ├── ProductCard.module.css │ │ └── ProductCard.tsx │ │ ├── ProductCardList │ │ ├── ProductCardList.module.css │ │ └── ProductCardList.tsx │ │ └── QuantityBlock │ │ ├── QuantityBlock.module.css │ │ └── QuantityBlock.tsx ├── constants │ ├── common.ts │ ├── lockedItems.ts │ ├── messages.ts │ └── routes.ts ├── hooks │ ├── useFilterByBrand.tsx │ ├── useForm.tsx │ └── useOutsideClick.tsx ├── index.css ├── index.tsx ├── logo.svg ├── mocks │ ├── brands.json │ ├── categories.json │ └── products.json ├── react-app-env.d.ts ├── store │ ├── BrandSlice.ts │ ├── CategorySlice.ts │ ├── CommonSlice.ts │ ├── ProductSlice.ts │ ├── UserSlice.ts │ └── store.ts ├── types │ └── common.ts └── utils │ ├── helpers.ts │ └── validators.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Интернет-магазин 2 | 3 | Пэт проект интернет-магазин с витриной и админкой на React, Redux-Toolkit, TypeScript. 4 | 5 | ## Кратко 6 | - В админке можно добавлять товары/категории/бренды. На витрине это все динамически рендерится. Можно оформлять заказы и просматривать их в админке. 7 | 8 | ## Деплой 9 | 10 | https://flourishing-tartufo-ae4f52.netlify.app/ 11 | 12 | ## Функционал 13 | 14 | ### Админка 15 | 16 | #### Страница заказов 17 | - На странице отображаются все оформленные заказы. По клику на заказ можно увидеть его состав и другую информацию 18 | 19 | #### Страница товаров 20 | 21 | - Кнопка Добавить товар не активна если нет ни одной категорий или ни одного бренда 22 | - В форме создания нового товара обязательные поля отмечены звездочками. Поля валидируются на пустое значение. Поле Изображение валидируется по регулярному выражению на соответствие прямой ссылке 23 | - По клику на кнопку Сохранить товар уходит POST запрос на сервер 24 | - Уже созданные товары можно редактировать и удалять. На сервер уходят запросы PATCH и DELETE. 25 | - Если при выполнении запроса с сервера возвращается ошибка, то рендерится всплывающее уведомление. 26 | - В списке товаров есть два фильтра: по категориям и брендам. Фильтры связаны и работают в обе стороны: если в фильтре по категориям выбрать категорию, то в фильтре по брендам будут отображаться только те бренды, у которых есть соответствующие категории. И наоборот, если сначала выбрать бренд, то в фильтре по категориям будут только категории соответствующего бренда. Список товаров на странице отображается в соответствии с выбранными значениями фильтров 27 | - Акционные товары с соответствующим бейджем 28 | 29 | #### Страница настроек 30 | 31 | - Можно добавлять, изменять или удалять категории и бренды (запросы GET, POST, PATCH, DELETE) 32 | - В карточках отображается количество товаров и количество товаров со скидкой у категорий и брендов 33 | - Категорию или бренд нельзя удалить если к ним привязаны товары. При попытке удалить рендерится уведомление 34 | - При изменении названия бренда или категории, актуальные данные отображаются на странице товаров в фильтрах и в самих товарах, а так же при создании нового товара. 35 | - Поля формы создания новой категорий или бренда валидируются на пустое значение 36 | - Если при выполнении сервер вернул ошибку, то рендерится уведомление 37 | 38 | ### Витрина 39 | 40 | Товары добавленные в *Избранные* и в корзину сохраняются в LS. При добавлении рендерится уведомление с ссылкой для перехода на соответствующую страницу. 41 | 42 | #### Шапка 43 | - Меню динамическое. В нем отображаются созданные в админке категории 44 | 45 | #### Роутинг 46 | - Главная страница — акционные товары (скидку можно задать в карточке товара в админке) 47 | - Страница категорий — динамический роутинг. Адрес страницы имеет следующий вид *site.com/:url*. Url можно задать в админке в настройках категории, таким образом ссылки получаются такими *site.com/jeans* 48 | - Карточка товара — вложенный динамический роутинг *site.com/:url/:id* 49 | - 404 страница для несуществующих роутов 50 | - Страница успешного оформления заказа с таймером обратного отсчета и редиректом на главную 51 | 52 | #### Каталог товаров 53 | - При рендере страницы товары сортируются по цене в возрастающием порядке 54 | - Порядок сортировки можно изменить по клику на *Сначала показывать* 55 | - Блок категорий товаров формируется исходя из категорий, которые были созданы в админке 56 | - Фильтр по брендам 57 | - Пагинация *Показать еще* 58 | - Описание категории рендерится если его указать в настройках категории 59 | - Товары можно добавить/удалить в *Избранные* 60 | - У карточек товаров рендерятся бейджи: размер скидки (если есть), производитель и категория 61 | 62 | #### Карточка товара 63 | - При добавлении товара в корзину рендерится уведомление с ссылкой для перехода в корзину 64 | - Товар можно добавить/удалить из *Избранного* 65 | 66 | #### Корзина 67 | - Можно изменить количество товара, добавить в *Избранное* или удалить. Можно удалить товар из корзины 68 | - В блоке *Итого* рассчитывается общая сумма заказа и вес (цену, скидку и вес можно указать в админке в карточке товара) 69 | - Поля формы валидируются на пустое значение 70 | 71 | 72 | 73 | ## Стек 74 | 75 | - React 76 | - Redux-Toolkit 77 | - TypeScript 78 | - Firebase 79 | - Адаптивная верстка 80 | 81 | ## Установка 82 | 83 | Для запуска на локальной машине необходимо:
84 | 85 | 1. Установить npm зависимости:
86 | 87 | ```sh 88 | npm install 89 | ``` 90 | 91 | 2. Запустить в режиме разработки:
92 | 93 | ```sh 94 | npm run start 95 | ``` 96 | 97 | Если все прошло успешно, проект будет запущен на `http://localhost:3000` 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e-commerce-react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.8.3", 7 | "@testing-library/jest-dom": "^5.16.4", 8 | "@testing-library/react": "^13.3.0", 9 | "@testing-library/user-event": "^14.3.0", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^17.0.45", 12 | "@types/react": "^18.0.15", 13 | "@types/react-dom": "^18.0.6", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-redux": "^8.0.2", 17 | "react-router-dom": "^6.3.0", 18 | "react-scripts": "5.0.1", 19 | "typescript": "^4.7.4", 20 | "uuidv4": "^6.2.13", 21 | "web-vitals": "^2.1.4" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rizametovd/e-commerce-react-app/c83a54a0b8df58c57a8bef5fc513acb088fc471f/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Интернет-магазин 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rizametovd/e-commerce-react-app/c83a54a0b8df58c57a8bef5fc513acb088fc471f/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rizametovd/e-commerce-react-app/c83a54a0b8df58c57a8bef5fc513acb088fc471f/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | box-sizing: border-box; 3 | min-height: 100vh; 4 | } 5 | 6 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import classes from './App.module.css'; 2 | import { PATHS } from './constants/routes'; 3 | import { useRoutes } from 'react-router-dom'; 4 | import { useEffect } from 'react'; 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | import { AppDispatch, RootState } from './store/store'; 7 | import { fetchProducts } from './store/ProductSlice'; 8 | import CategoryPage from './components/pages/showcasePages/CategoryPage/CategoryPage'; 9 | import ProductsPage from './components/pages/adminPages/ProductsPage/ProductsPage'; 10 | import SettingsPage from './components/pages/adminPages/SettingsPage/SettingsPage'; 11 | import AdminPage from './components/pages/adminPages/AdminPage/AdminPage'; 12 | import DiscountProductsPage from './components/pages/showcasePages/DiscountProductsPage/DiscountProductsPage'; 13 | import ShowcasePage from './components/pages/showcasePages/ShowcasePage/ShowcasePage'; 14 | import { getFromLocalStorage } from './store/UserSlice'; 15 | import WishlistPage from './components/pages/showcasePages/WishlistPage/WishlistPage'; 16 | import ProductPage from './components/pages/showcasePages/ProductPage/ProductPage'; 17 | import Loader from './components/UI/Loader/Loader'; 18 | import CartPage from './components/pages/showcasePages/CartPage/CartPage'; 19 | import OrdersPage from './components/pages/adminPages/OrdersPage/OrdersPage'; 20 | import CheckoutSuccessPage from './components/pages/showcasePages/CheckoutSuccessPage/CheckoutSuccessPage'; 21 | import NotFound from './components/pages/showcasePages/NotFound/NotFound'; 22 | 23 | const App = () => { 24 | const dispatch = useDispatch(); 25 | const { isLoading, products } = useSelector((state: RootState) => state.product); 26 | const isDataLoaded = !isLoading && products.length > 0; 27 | 28 | const routes = useRoutes([ 29 | { 30 | path: PATHS.showcase, 31 | element: , 32 | children: [ 33 | { 34 | path: '/', 35 | element: isDataLoaded ? : , 36 | }, 37 | { 38 | path: ':url', 39 | children: [ 40 | { 41 | index: true, 42 | element: isDataLoaded ? : , 43 | }, 44 | { 45 | path: ':id', 46 | element: isDataLoaded ? : , 47 | }, 48 | ], 49 | }, 50 | { path: PATHS.wishlist, element: isDataLoaded ? : }, 51 | { 52 | path: PATHS.cart, 53 | element: isDataLoaded ? : , 54 | }, 55 | { 56 | path: `${PATHS.cart}/${PATHS.success}`, 57 | element: , 58 | }, 59 | ], 60 | }, 61 | { 62 | path: PATHS.admin, 63 | 64 | element: , 65 | children: [ 66 | { 67 | index: true, 68 | path: PATHS.orders, 69 | element: , 70 | }, 71 | { 72 | path: PATHS.products, 73 | element: , 74 | }, 75 | { 76 | path: PATHS.settings, 77 | element: , 78 | }, 79 | ], 80 | }, 81 | 82 | { 83 | path: '*', 84 | element: , 85 | }, 86 | ]); 87 | 88 | useEffect(() => { 89 | const fetchData = async () => { 90 | try { 91 | dispatch(getFromLocalStorage('wishlist')); 92 | dispatch(getFromLocalStorage('cart')); 93 | dispatch(fetchProducts()); 94 | } catch (error) { 95 | console.log('Fetch error App.tsx:', error); 96 | } 97 | }; 98 | 99 | fetchData(); 100 | }, [dispatch]); 101 | 102 | return
{routes}
; 103 | }; 104 | 105 | export default App; 106 | -------------------------------------------------------------------------------- /src/assets/aa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rizametovd/e-commerce-react-app/c83a54a0b8df58c57a8bef5fc513acb088fc471f/src/assets/aa.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rizametovd/e-commerce-react-app/c83a54a0b8df58c57a8bef5fc513acb088fc471f/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/UI/AreaLoader/AreaLoader.module.css: -------------------------------------------------------------------------------- 1 | .loader { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: white; 8 | border-radius: 20px; 9 | box-sizing: border-box; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | animation: fade 100ms linear forwards; 14 | z-index: 999; 15 | } 16 | 17 | .text { 18 | font-family: 'Nunito'; 19 | font-weight: 700; 20 | font-size: 28px; 21 | padding-bottom: 8px; 22 | background: linear-gradient(#5ec343 0 0) bottom left/0% 5px no-repeat; 23 | animation: c2 2s linear infinite; 24 | } 25 | 26 | @keyframes c2 { 27 | to { 28 | background-size: 100% 3px; 29 | opacity: 1; 30 | } 31 | } 32 | 33 | @keyframes fade { 34 | from { 35 | opacity: 0; 36 | } 37 | to { 38 | opacity: 0.8; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/UI/AreaLoader/AreaLoader.tsx: -------------------------------------------------------------------------------- 1 | import classes from './AreaLoader.module.css'; 2 | 3 | const AreaLoader: React.FC = () => { 4 | return
5 |
Обновляю...
6 |
7 | } 8 | 9 | export default AreaLoader; -------------------------------------------------------------------------------- /src/components/UI/Badge/Badge.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | text-decoration: none; 3 | } 4 | 5 | .badge { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | .wrapper { 11 | position: relative; 12 | } 13 | 14 | .count { 15 | min-width: 18px; 16 | height: 18px; 17 | position: absolute; 18 | top: -9px; 19 | right: -8px; 20 | font-size: 10px; 21 | background-color: #f44336; 22 | color: #fff; 23 | border-radius: 50%; 24 | align-self: center; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | z-index: 2; 29 | } 30 | 31 | .text { 32 | font-size: 12px; 33 | color: #222222; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/UI/Badge/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import classes from './Badge.module.css'; 3 | 4 | interface IBadgeProps { 5 | icon: JSX.Element; 6 | count: number; 7 | title?: string; 8 | to: string; 9 | } 10 | 11 | const Badge: React.FC = ({ icon, to, count, title='' }) => { 12 | return ( 13 | 14 |
15 |
16 | {count > 0 &&
{count}
} 17 | {icon} 18 |
19 | {title && {title}} 20 |
21 | 22 | ); 23 | }; 24 | 25 | export default Badge; 26 | -------------------------------------------------------------------------------- /src/components/UI/BaseLink/BaseLink.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | text-decoration: none; 3 | color: #333333; 4 | } 5 | 6 | .button { 7 | background-color: #fc0; 8 | border-radius: 8px; 9 | } 10 | 11 | .button:hover:not(:disabled) { 12 | transition: opacity 0.3s ease-out; 13 | opacity: 0.5; 14 | } 15 | 16 | .button:focus:active { 17 | outline: 2px solid transparent; 18 | } 19 | 20 | .button:focus:not(:disabled) { 21 | transition: all 250ms linear; 22 | border: 1px solid #ffc43f; 23 | } 24 | 25 | .s { 26 | font-size: 12px; 27 | padding: 5px 10px; 28 | border-radius: 4px; 29 | } 30 | 31 | .m { 32 | font-size: 16px; 33 | padding: 10px 20px; 34 | border-radius: 8px; 35 | } -------------------------------------------------------------------------------- /src/components/UI/BaseLink/BaseLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import classes from './BaseLink.module.css'; 3 | 4 | interface IBaseLinkProps { 5 | children: string; 6 | to: string; 7 | button?: boolean; 8 | size: 's' | 'm'; 9 | } 10 | const BaseLink: React.FC = ({ children, to, button, size = 'm' }) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }; 17 | 18 | export default BaseLink; 19 | -------------------------------------------------------------------------------- /src/components/UI/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | font-family: 'Open Sans'; 3 | font-size: 15px; 4 | color: #222222; 5 | cursor: pointer; 6 | border: none; 7 | background: transparent; 8 | padding: 7px 17px; 9 | box-sizing: border-box; 10 | border-radius: 4px; 11 | position: relative; 12 | } 13 | 14 | .button:hover:not(:disabled) { 15 | transition: opacity 0.3s ease-out; 16 | opacity: 0.5; 17 | } 18 | 19 | .button:disabled::before { 20 | content: ''; 21 | display: block; 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | bottom: 0; 26 | right: 0; 27 | width: 100%; 28 | height: 100%; 29 | transition: all 250ms linear; 30 | background-color: #f2f2f2; 31 | opacity: 0.6; 32 | cursor: default; 33 | } 34 | 35 | .primary { 36 | color: #fff; 37 | background-color: #5ec343; 38 | } 39 | 40 | .secondary { 41 | background-color: transparent; 42 | color: #333333; 43 | border: 1px solid lightgray; 44 | } 45 | -------------------------------------------------------------------------------- /src/components/UI/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from '../Spinner/Spinner'; 2 | import classes from './Button.module.css'; 3 | 4 | interface IButtonProps { 5 | children?: string; 6 | mode: 'primary' | 'secondary'; 7 | type?: 'button' | 'submit'; 8 | isDisabled?: boolean; 9 | onClick?: () => void; 10 | isLoading?: boolean 11 | } 12 | 13 | const Button: React.FC = ({ type = 'button', children, onClick, mode, isDisabled = false, isLoading }) => { 14 | return ( 15 | 19 | ); 20 | }; 21 | 22 | export default Button; 23 | -------------------------------------------------------------------------------- /src/components/UI/Card/Card.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | width: 100%; 3 | max-width: 960px; 4 | display: inline-block; 5 | padding: 16px; 6 | box-sizing: border-box; 7 | border: 1px solid #fbfbfb; 8 | border-radius: 4px; 9 | background-color: #fff; 10 | position: relative; 11 | animation: fade 200ms linear; 12 | } 13 | 14 | .full-width { 15 | min-width: 100%; 16 | } 17 | 18 | @keyframes fade { 19 | from { 20 | opacity: 0; 21 | } 22 | to { 23 | opacity: 1; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/UI/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Card.module.css'; 2 | 3 | interface ICardProps { 4 | children: JSX.Element, 5 | fullWidth?: boolean 6 | } 7 | 8 | const Card: React.FC = ({children, fullWidth = false}) => { 9 | return ( 10 |
{children}
11 | ) 12 | } 13 | 14 | export default Card; -------------------------------------------------------------------------------- /src/components/UI/Checkbox/Checkbox.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: block; 3 | position: relative; 4 | padding-left: 30px; 5 | margin-bottom: 12px; 6 | cursor: pointer; 7 | font-size: 14px; 8 | } 9 | 10 | .container input { 11 | position: absolute; 12 | opacity: 0; 13 | cursor: pointer; 14 | height: 0; 15 | width: 0; 16 | } 17 | 18 | .checkmark { 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | height: 18px; 23 | width: 18px; 24 | border-radius: 3px; 25 | background-color: #fff; 26 | outline: 2px solid lightgray; 27 | } 28 | 29 | .container:hover input ~ .checkmark { 30 | background-color: #f2f2f2; 31 | transition: all 150ms linear; 32 | } 33 | 34 | .container input:checked ~ .checkmark { 35 | border: none; 36 | outline-color: #fc0; 37 | background-color: #fc0; 38 | } 39 | 40 | .checkmark:after { 41 | content: ''; 42 | position: absolute; 43 | display: none; 44 | } 45 | 46 | .container input:checked ~ .checkmark:after { 47 | display: block; 48 | } 49 | 50 | .container .checkmark:after { 51 | left: 6px; 52 | top: 2px; 53 | width: 3px; 54 | height: 8px; 55 | border: solid rgba(0, 0, 0, 0.6); 56 | border-width: 0 3px 3px 0; 57 | transform: rotate(45deg); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/UI/Checkbox/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import classes from './Checkbox.module.css'; 4 | 5 | interface ICheckboxProps { 6 | onCheck: () => void; 7 | label: string; 8 | } 9 | 10 | const Checkbox: React.FC = ({ onCheck, label }) => { 11 | const [isChecked, setIsChecked] = useState(false); 12 | const { url } = useParams(); 13 | 14 | useEffect(() => { 15 | if (url) { 16 | setIsChecked(false); 17 | } 18 | }, [url]); 19 | 20 | const handleChange = () => { 21 | setIsChecked((prev) => !prev); 22 | onCheck(); 23 | }; 24 | 25 | return ( 26 | 31 | ); 32 | }; 33 | 34 | export default Checkbox; 35 | -------------------------------------------------------------------------------- /src/components/UI/Chip/Chip.module.css: -------------------------------------------------------------------------------- 1 | .chip { 2 | font-size: 12px; 3 | border-radius: 4px; 4 | padding: 5px 10px; 5 | align-self: flex-start; 6 | } 7 | 8 | .info { 9 | background-color: #fcf7eb; 10 | } 11 | 12 | .attention { 13 | color: #fff; 14 | background-color: #f44336; 15 | } 16 | 17 | .highlighted { 18 | background-color: #ecf7ed; 19 | } 20 | 21 | .plain { 22 | border: 1px solid lightgray; 23 | background-color: #fff; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/UI/Chip/Chip.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Chip.module.css' 2 | 3 | interface IChipProps { 4 | text: string; 5 | mode?: 'info' | 'attention' | 'highlighted' | 'plain' 6 | } 7 | 8 | const Chip: React.FC = ({text, mode='info'}) => { 9 | return {text} 10 | } 11 | 12 | export default Chip; -------------------------------------------------------------------------------- /src/components/UI/Form/Form.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 20px; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/UI/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Form.module.css'; 2 | 3 | interface IFormProps { 4 | onSubmit: (e: React.FormEvent) => void; 5 | children: JSX.Element; 6 | } 7 | 8 | const Form: React.FC = ({ children, onSubmit }) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default Form; 17 | -------------------------------------------------------------------------------- /src/components/UI/IconButton/IconButton.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | border: none; 6 | outline: none; 7 | cursor: pointer; 8 | background-color: transparent; 9 | padding: 0; 10 | gap: 5px; 11 | fill: lightgray; 12 | } 13 | 14 | .button:disabled { 15 | cursor: default; 16 | fill: lightgray; 17 | } 18 | 19 | .button:hover:not(:disabled) { 20 | fill: #4faa37; 21 | } 22 | 23 | .column { 24 | flex-direction: column; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/UI/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import classes from './IconButton.module.css'; 2 | 3 | interface IIconButtonProps { 4 | children: JSX.Element; 5 | onClick: () => void; 6 | isDisabled?: boolean; 7 | column?: boolean; 8 | type?: 'button' | 'submit'; 9 | } 10 | 11 | const IconButton: React.FC = ({ children, onClick, isDisabled, type = 'button', column}) => { 12 | const handleClick = (e: React.MouseEvent) => { 13 | e.preventDefault(); 14 | onClick(); 15 | }; 16 | 17 | return ( 18 | 21 | ); 22 | }; 23 | 24 | export default IconButton; 25 | -------------------------------------------------------------------------------- /src/components/UI/Input/Input.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 5px; 5 | width: 100%; 6 | height: 98px; 7 | } 8 | 9 | .label { 10 | font-family: 'Nunito'; 11 | font-size: 18px; 12 | font-weight: 600; 13 | line-height: 22px; 14 | color: #333333; 15 | } 16 | 17 | .input { 18 | font-family: 'Open Sans'; 19 | font-size: 16px; 20 | line-height: 20px; 21 | color: #222222; 22 | padding: 10px 20px; 23 | border-radius: 4px; 24 | border: 1px solid lightgray; 25 | 26 | outline: 2px solid transparent; 27 | } 28 | 29 | .input::placeholder { 30 | font-size: 14px; 31 | color: lightgray; 32 | } 33 | 34 | .input:focus { 35 | transition: all 250ms linear; 36 | background-color: #fcf7eb; 37 | border: 1px solid #ffc43f; 38 | } 39 | 40 | .error { 41 | transition: outline 0.3s ease-in; 42 | outline: 2px solid #ef2525; 43 | } 44 | 45 | .highlighted { 46 | font-size: 14px; 47 | color: #ef2525; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/UI/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Input.module.css'; 2 | 3 | interface IInputProps { 4 | label: string; 5 | errorText?: string; 6 | name: string; 7 | type: string; 8 | required?: boolean; 9 | placeholder: string; 10 | onChange: (e: React.ChangeEvent) => void; 11 | value: string; 12 | isDisabled?: boolean 13 | } 14 | 15 | const Input: React.FC = ({ 16 | label, 17 | errorText, 18 | name, 19 | type, 20 | required = false, 21 | placeholder, 22 | onChange, 23 | value, 24 | isDisabled 25 | }) => { 26 | return ( 27 |
28 | 31 | 42 | {errorText && {errorText}} 43 |
44 | ); 45 | }; 46 | 47 | export default Input; 48 | -------------------------------------------------------------------------------- /src/components/UI/LoadMore/LoadMore.tsx: -------------------------------------------------------------------------------- 1 | import Button from '../Button/Button'; 2 | 3 | interface ILoadMoreProps { 4 | count: number; 5 | itemsListLength: number; 6 | onClick: (count: number) => void; 7 | itemsLimit: number; 8 | } 9 | 10 | const LoadMore: React.FC = ({ count, itemsListLength, onClick, itemsLimit }) => { 11 | const clickHandler = () => { 12 | if (count >= itemsListLength) return; 13 | 14 | count += itemsLimit; 15 | onClick(count); 16 | }; 17 | 18 | return ( 19 | 22 | ); 23 | }; 24 | 25 | export default LoadMore; 26 | -------------------------------------------------------------------------------- /src/components/UI/Loader/Loader.module.css: -------------------------------------------------------------------------------- 1 | .lds-ring { 2 | display: inline-block; 3 | position: absolute; 4 | top: 100px; 5 | left: 50%; 6 | transform: translateX(-50%); 7 | width: 60px; 8 | height: 60px; 9 | z-index: 1; 10 | } 11 | .lds-ring div { 12 | box-sizing: border-box; 13 | display: block; 14 | position: absolute; 15 | width: 64px; 16 | height: 64px; 17 | margin: 8px; 18 | border: 8px solid #5ec343; 19 | border-radius: 50%; 20 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 21 | border-color: #5ec343 transparent transparent transparent; 22 | } 23 | .lds-ring div:nth-child(1) { 24 | animation-delay: -0.45s; 25 | } 26 | .lds-ring div:nth-child(2) { 27 | animation-delay: -0.3s; 28 | } 29 | .lds-ring div:nth-child(3) { 30 | animation-delay: -0.15s; 31 | } 32 | @keyframes lds-ring { 33 | 0% { 34 | transform: rotate(0deg); 35 | } 36 | 100% { 37 | transform: rotate(360deg); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/UI/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Loader.module.css'; 2 | 3 | export const Loader: React.FC = () => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 | ); 12 | }; 13 | 14 | export default Loader; 15 | -------------------------------------------------------------------------------- /src/components/UI/Placeholder/Placeholder.module.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | font-family: 'Nunito'; 3 | color: lightgray; 4 | text-align: center; 5 | } -------------------------------------------------------------------------------- /src/components/UI/Placeholder/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Placeholder.module.css'; 2 | 3 | interface IPlaceholderProps { 4 | text: string; 5 | size?: string; 6 | } 7 | 8 | const Placeholder: React.FC = ({ text, size = '36px' }) => { 9 | return ( 10 |

11 | {text} 12 |

13 | ); 14 | }; 15 | 16 | export default Placeholder; 17 | -------------------------------------------------------------------------------- /src/components/UI/Select/Select.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | width: 100%; 3 | border: 1px solid lightgray; 4 | border-radius: 4px; 5 | padding: 9px 20px; 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: baseline; 9 | position: relative; 10 | box-sizing: border-box; 11 | outline: 2px solid transparent; 12 | } 13 | 14 | .default-value { 15 | cursor: default; 16 | } 17 | 18 | .disabled { 19 | color: #c3c3c3; 20 | background: #f2f2f2; 21 | } 22 | 23 | 24 | 25 | .select::after { 26 | content: ''; 27 | width: 12px; 28 | height: 8px; 29 | background-color: black; 30 | clip-path: polygon(100% 0%, 0 0%, 50% 100%); 31 | transition: background-color 100ms linear; 32 | } 33 | 34 | .disabled.select::after { 35 | transition: all 100ms linear; 36 | background-color: #c3c3c3; 37 | 38 | } 39 | 40 | 41 | .select:focus:not(.disabled) { 42 | transition: all 250ms linear; 43 | background-color: #fcf7eb; 44 | border-color: #ffc43f; 45 | } 46 | 47 | .options { 48 | display: flex; 49 | flex-direction: column; 50 | list-style: none; 51 | padding: 12px 0; 52 | margin: 0; 53 | position: absolute; 54 | top: 45px; 55 | right: 0; 56 | left: 0; 57 | z-index: 1; 58 | border: 1px solid lightgray; 59 | border-radius: 4px; 60 | background-color: white; 61 | box-shadow: 0px 5px 22px rgba(0, 0, 0, 0.1); 62 | } 63 | 64 | .active { 65 | background-color: #ffc43f; 66 | } 67 | 68 | .option { 69 | cursor: default; 70 | padding: 10px 0 10px 20px; 71 | } 72 | 73 | .option:hover { 74 | transition: all 150ms linear; 75 | background-color: #ffc43f; 76 | } 77 | 78 | 79 | 80 | .error { 81 | transition: outline 0.3s ease-in; 82 | outline: 2px solid #ef2525; 83 | } -------------------------------------------------------------------------------- /src/components/UI/Select/Select.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import useOutsideClick from '../../../hooks/useOutsideClick'; 3 | import { Option } from '../../../types/common'; 4 | import classes from './Select.module.css'; 5 | 6 | interface ISelectProps { 7 | options: Option[]; 8 | defaultOptionText: string; 9 | isDisabled?: boolean; 10 | label: string; 11 | required?: boolean; 12 | onSelect: (option: { [key: string]: string }) => void; 13 | value: string; 14 | field: string; 15 | errorText?: string; 16 | } 17 | 18 | const Select: React.FC = ({ 19 | options, 20 | defaultOptionText, 21 | isDisabled, 22 | onSelect, 23 | required, 24 | label, 25 | value, 26 | field, 27 | errorText, 28 | }) => { 29 | const [isOpen, setIsOpen] = useState(false); 30 | 31 | const handleOpen = () => { 32 | if (isDisabled) return; 33 | setIsOpen((prev) => !prev); 34 | }; 35 | 36 | const handleClickOutside = () => { 37 | setIsOpen(false); 38 | }; 39 | 40 | const ref = useOutsideClick(handleClickOutside); 41 | 42 | return ( 43 |
44 | {value ? value : defaultOptionText} 45 | 46 | {isOpen && ( 47 |
    48 |
  • onSelect({ field })}> 49 | {defaultOptionText} 50 |
  • 51 | {options.map(({ id, name, url }) => ( 52 |
  • onSelect({ name, id, field, url })} 56 | > 57 | {name} 58 |
  • 59 | ))} 60 |
61 | )} 62 |
63 | ); 64 | }; 65 | 66 | export default Select; 67 | -------------------------------------------------------------------------------- /src/components/UI/Spinner/Spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | border-radius: 4px; 3 | position: absolute; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | bottom: 0; 11 | opacity: 0.7; 12 | cursor: default; 13 | } 14 | 15 | .spinner::before { 16 | content: ''; 17 | width: 19px; 18 | height: 19px; 19 | border: 3px solid lightgray; 20 | border-bottom-color: green; 21 | border-radius: 50%; 22 | box-sizing: border-box; 23 | animation: rotation 1s linear infinite; 24 | } 25 | 26 | @keyframes rotation { 27 | 0% { 28 | transform: rotate(0deg); 29 | } 30 | 100% { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/UI/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Spinner.module.css'; 2 | 3 | const Spinner: React.FC = () => { 4 | return
; 5 | }; 6 | 7 | export default Spinner; 8 | -------------------------------------------------------------------------------- /src/components/UI/Textarea/Textarea.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 5px; 5 | height: 194px; 6 | } 7 | 8 | .label { 9 | font-family: 'Nunito'; 10 | font-size: 18px; 11 | font-weight: 600; 12 | line-height: 22px; 13 | color: #333333; 14 | } 15 | 16 | .textarea { 17 | font-family: 'Open Sans'; 18 | font-size: 16px; 19 | line-height: 20px; 20 | color: #222222; 21 | padding: 10px 20px; 22 | border-radius: 4px; 23 | border: 1px solid lightgray; 24 | outline: 2px solid transparent; 25 | resize: none; 26 | } 27 | 28 | .textarea::placeholder { 29 | font-size: 14px; 30 | color: lightgray; 31 | } 32 | 33 | .textarea:focus { 34 | transition: all 250ms linear; 35 | background-color: #fcf7eb; 36 | border: 1px solid #ffc43f; 37 | } 38 | 39 | .error { 40 | transition: outline 0.3s ease-in; 41 | outline: 2px solid #ef2525; 42 | } 43 | 44 | .highlighted { 45 | font-size: 14px; 46 | color: #ef2525; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/UI/Textarea/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import classes from './Textarea.module.css'; 2 | 3 | interface ITextareaProps { 4 | label: string; 5 | errorText?: string; 6 | name: string; 7 | required?: boolean; 8 | placeholder: string; 9 | value: string; 10 | onChange: (e: React.ChangeEvent) => void; 11 | } 12 | 13 | const Textarea: React.FC = ({ 14 | label, 15 | errorText, 16 | name, 17 | required = false, 18 | placeholder, 19 | onChange, 20 | value, 21 | }) => { 22 | return ( 23 |
24 | 27 |