diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 0f5ba90b0..fcd99394f 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -35,27 +35,27 @@ jobs:
run: npm run lint && npm test
# Собираем приложение
- - name: Build Application
- run: npm run build
+ # - name: Build Application
+ # run: npm run build
# Публикуем приложение на Github Pages
- - name: Deploy to Github Pages
+ # - name: Deploy to Github Pages
+ # uses: JamesIves/github-pages-deploy-action@4.2.1
+ # with:
+ # branch: gh-pages
+ # folder: dist
+
+ # Собираем Storybook
+ - name: Build Storybook
+ run: npm run build-storybook
+
+ # Публикуем Storybook на Github Pages
+ - name: Deploy Storybook to Github Pages
uses: JamesIves/github-pages-deploy-action@4.2.1
with:
branch: gh-pages
- folder: dist
-
- # # Собираем Storybook
- # - name: Build Storybook
- # run: npm run build-storybook
- #
- # # Публикуем Storybook на Github Pages
- # - name: Deploy Storybook to Github Pages
- # uses: JamesIves/github-pages-deploy-action@4.2.1
- # with:
- # branch: gh-pages
- # folder: storybook-static
- # commit-message: "Automatically publish Storybook"
+ folder: storybook-static
+ commit-message: "Automatically publish Storybook"
# Останавливаем выполнение строго при неудачных тестах
- name: Fail on failed tests
diff --git a/package-lock.json b/package-lock.json
index 5b1dfa392..b9fef52d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,9 +9,12 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "@hookform/resolvers": "^3.10.0",
"clsx": "^1.2.1",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-hook-form": "^7.54.2",
+ "zod": "^3.24.1"
},
"devDependencies": {
"@babel/core": "^7.22.1",
@@ -2620,6 +2623,15 @@
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==",
"dev": true
},
+ "node_modules/@hookform/resolvers": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
+ "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react-hook-form": "^7.0.0"
+ }
+ },
"node_modules/@humanwhocodes/config-array": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz",
@@ -19941,6 +19953,22 @@
"integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
"dev": true
},
+ "node_modules/react-hook-form": {
+ "version": "7.54.2",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
+ "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -23542,6 +23570,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zod": {
+ "version": "3.24.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
+ "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
@@ -25246,6 +25283,12 @@
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==",
"dev": true
},
+ "@hookform/resolvers": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
+ "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
+ "requires": {}
+ },
"@humanwhocodes/config-array": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz",
@@ -37986,6 +38029,12 @@
}
}
},
+ "react-hook-form": {
+ "version": "7.54.2",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
+ "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
+ "requires": {}
+ },
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -40641,6 +40690,11 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
},
+ "zod": {
+ "version": "3.24.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
+ "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="
+ },
"zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 492664d1f..d77f70944 100644
--- a/package.json
+++ b/package.json
@@ -64,8 +64,11 @@
"webpack-dev-server": "^4.15.0"
},
"dependencies": {
+ "@hookform/resolvers": "^3.10.0",
"clsx": "^1.2.1",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-hook-form": "^7.54.2",
+ "zod": "^3.24.1"
}
}
diff --git a/src/app/App.css b/src/app/App.css
index 78b8850cf..05a3f9709 100644
--- a/src/app/App.css
+++ b/src/app/App.css
@@ -1,38 +1,44 @@
.App {
- text-align: center;
+ text-align: center;
+ background-color: var(--background-color);
+ color: var(--color);
}
.App-logo {
- height: 40vmin;
- pointer-events: none;
+ height: 40vmin;
+ pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
- .App-logo {
- animation: App-logo-spin infinite 20s linear;
- }
+ .App-logo {
+ animation: App-logo-spin infinite 20s linear;
+ }
}
.App-header {
- background-color: #282c34;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+}
+
+.App-nav {
+ display: flex;
+ justify-content: center;
+ gap: 15px;
}
.App-link {
- color: #61dafb;
+ color: #61dafb;
}
@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
}
diff --git a/src/app/App.tsx b/src/app/App.tsx
index dcc0ff8ad..2929056c4 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -2,16 +2,57 @@ import React from 'react';
import logo from './logo.svg';
import './App.css';
+import { ThemeProvider } from '../shared/providers/ThemeProvider';
+import { LangProvider, Content, useLang } from '../shared/providers/LangProvider';
+import { ThemeSwitcher } from '../shared/theme-switcher/ThemeSwitcher';
+import { LangSwitcher } from '../shared/lang-switcher/LangSwitcher';
+
+const MainContent = () => {
+ const content: Content = {
+ fullname: { ru: 'Игорь Аралов', en: 'Igor Aralov' },
+ target: {
+ ru: 'Научиться разрабатывать веб-приложения при помощи React',
+ en: 'Learn to develop web applications using React',
+ },
+ skills: {
+ ru: 'Профессионально работаю SQL разработчиком уже 10 лет и принимаю участие в разработке десктопного приложения на Delphi',
+ en: 'I have been working professionally as an SQL developer for 10 years and am involved in the development of a desktop application using Delphi.',
+ },
+ hobby: {
+ ru: 'Интересуюсь фронтенд и фуллстек разработкой на js/ts',
+ en: "I'm interested in frontend and full-stack development using js/ts",
+ },
+ };
+
+ const { useContent } = useLang();
+ const l = useContent(content);
+
+ return (
+ <>
+
{l('fullname')} (igor.aralov@rambler.ru)
+ {l('target')}
+ {l('skills')}
+ {l('hobby')}
+ >
+ );
+};
+
function App() {
return (
-
-
-
-
- Текст писать тут
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/index.css b/src/app/index.css
index 4b326a5a4..0ca6b3986 100644
--- a/src/app/index.css
+++ b/src/app/index.css
@@ -1,13 +1,21 @@
+[data-theme='light'] {
+ --background-color: #fff;
+ --color: #000;
+}
+
+[data-theme='dark'] {
+ --background-color: #333;
+ --color: #fff;
+}
+
body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
- 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
- 'Helvetica Neue', sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
+ 'Droid Sans', 'Helvetica Neue', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
diff --git a/src/homeworks/homework-10/AccountService.test.ts b/src/homeworks/homework-10/AccountService.test.ts
new file mode 100644
index 000000000..b2cc09884
--- /dev/null
+++ b/src/homeworks/homework-10/AccountService.test.ts
@@ -0,0 +1,121 @@
+import { IDatabase } from './Database';
+import { IDiscountService, DiscountService } from './DiscountService';
+import { AccountService } from './AccountService';
+import { Product, User } from './Entities';
+
+class MockDatabase implements IDatabase {
+ private data: Map = new Map();
+
+ async save(key: string, value: T): Promise {
+ this.data.set(key, value);
+ return true;
+ }
+
+ async load(key: string): Promise {
+ const value = this.data.get(key);
+ if (value === undefined) {
+ throw new Error('Table not found');
+ }
+ return { ...(value as T) };
+ }
+}
+
+describe('AccountService', () => {
+ let discountService: IDiscountService;
+ let accountService: AccountService;
+
+ beforeEach(() => {
+ const db = new MockDatabase();
+ discountService = new DiscountService(db);
+ accountService = new AccountService(discountService);
+ });
+
+ test('should throw table not found error', async () => {
+ let err;
+ try {
+ await accountService.userDiscount.loadDiscounts();
+ } catch (e) {
+ err = e;
+ } finally {
+ expect(err.message).toBe('Failed to load user_discount discounts');
+ }
+ });
+
+ test('should set and get user discount', async () => {
+ await accountService.userDiscount.setUserDiscount(User.Standard, 10);
+ await accountService.userDiscount.saveDiscounts();
+ await accountService.userDiscount.loadDiscounts();
+
+ expect(accountService.userDiscount.getUserDiscount(User.Standard)).toBe(10);
+ });
+
+ test('should get user discount = 0 for negative value', async () => {
+ await accountService.userDiscount.setUserDiscount(User.Standard, -10);
+ await accountService.userDiscount.saveDiscounts();
+ await accountService.userDiscount.loadDiscounts();
+
+ expect(accountService.userDiscount.getUserDiscount(User.Standard)).toBe(0);
+ });
+
+ test('should calculate user discount = 100 for value > 100', async () => {
+ await accountService.userDiscount.setUserDiscount(User.Standard, 200);
+ await accountService.userDiscount.saveDiscounts();
+ await accountService.userDiscount.loadDiscounts();
+
+ expect(accountService.userDiscount.getUserDiscount(User.Standard)).toBe(100);
+ });
+
+ test('should set and get user product discount', async () => {
+ await accountService.userProductDiscount.setUserProductDiscount(User.Standard, Product.Car, 10);
+ await accountService.userProductDiscount.saveDiscounts();
+ await accountService.userProductDiscount.loadDiscounts();
+
+ expect(accountService.userProductDiscount.getUserProductDiscount(User.Standard, Product.Car)).toBe(10);
+ });
+
+ test('should get user product discount = 0 for negative value', async () => {
+ await accountService.userProductDiscount.setUserProductDiscount(User.Standard, Product.Car, -10);
+ await accountService.userProductDiscount.saveDiscounts();
+ await accountService.userProductDiscount.loadDiscounts();
+
+ expect(accountService.userProductDiscount.getUserProductDiscount(User.Standard, Product.Car)).toBe(0);
+ });
+
+ test('should calculate user product discount = 100 for value > 100', async () => {
+ await accountService.userProductDiscount.setUserProductDiscount(User.Standard, Product.Car, 200);
+ await accountService.userProductDiscount.saveDiscounts();
+ await accountService.userProductDiscount.loadDiscounts();
+
+ expect(accountService.userProductDiscount.getUserProductDiscount(User.Standard, Product.Car)).toBe(100);
+ });
+
+ test('should calculate total discount', async () => {
+ await accountService.userDiscount.setUserDiscount(User.Standard, 10);
+ await accountService.userProductDiscount.setUserProductDiscount(User.Standard, Product.Car, 20);
+ await accountService.saveAllDiscounts();
+ await accountService.loadAllDiscounts();
+
+ const totalDiscount = accountService.calculateTotalDiscount(User.Standard, Product.Car);
+ expect(totalDiscount).toBe(30);
+ });
+
+ test('should calculate total discount = 0 for negative value', async () => {
+ await accountService.userDiscount.setUserDiscount(User.Standard, -10);
+ await accountService.userProductDiscount.setUserProductDiscount(User.Standard, Product.Car, -20);
+ await accountService.saveAllDiscounts();
+ await accountService.loadAllDiscounts();
+
+ const totalDiscount = accountService.calculateTotalDiscount(User.Standard, Product.Car);
+ expect(totalDiscount).toBe(0);
+ });
+
+ test('should calculate total discount = 100 for value > 100', async () => {
+ await accountService.userDiscount.setUserDiscount(User.Standard, 200);
+ await accountService.userProductDiscount.setUserProductDiscount(User.Standard, Product.Car, 10);
+ await accountService.saveAllDiscounts();
+ await accountService.loadAllDiscounts();
+
+ const totalDiscount = accountService.calculateTotalDiscount(User.Standard, Product.Car);
+ expect(totalDiscount).toBe(100);
+ });
+});
diff --git a/src/homeworks/homework-10/AccountService.ts b/src/homeworks/homework-10/AccountService.ts
new file mode 100644
index 000000000..5cda78632
--- /dev/null
+++ b/src/homeworks/homework-10/AccountService.ts
@@ -0,0 +1,28 @@
+import { UserDiscount, UserProductDiscount, IDiscountService } from './DiscountService';
+import { Product, User } from './Entities';
+
+export class AccountService {
+ userDiscount: UserDiscount;
+ userProductDiscount: UserProductDiscount;
+ private discountService: IDiscountService;
+
+ constructor(discountService: IDiscountService) {
+ this.discountService = discountService;
+ this.userDiscount = new UserDiscount(this.discountService);
+ this.userProductDiscount = new UserProductDiscount(this.discountService);
+ }
+
+ async loadAllDiscounts() {
+ Promise.all([this.userDiscount.loadDiscounts(), this.userProductDiscount.loadDiscounts()]);
+ }
+
+ async saveAllDiscounts() {
+ Promise.all([this.userDiscount.saveDiscounts(), this.userProductDiscount.saveDiscounts()]);
+ }
+
+ calculateTotalDiscount(user: User, product: Product) {
+ const userDiscount = this.userDiscount.getUserDiscount(user);
+ const userProductDiscount = this.userProductDiscount.getUserProductDiscount(user, product);
+ return this.discountService.totalDiscount(userDiscount, userProductDiscount);
+ }
+}
diff --git a/src/homeworks/homework-10/Database.ts b/src/homeworks/homework-10/Database.ts
new file mode 100644
index 000000000..4eee64319
--- /dev/null
+++ b/src/homeworks/homework-10/Database.ts
@@ -0,0 +1,33 @@
+export interface IDatabase {
+ save(key: string, value: T): Promise;
+ load(key: string): Promise;
+}
+
+export class Database implements IDatabase {
+ private data: Map;
+
+ constructor() {
+ this.data = new Map();
+ }
+
+ save(key: string, value: T): Promise {
+ return new Promise((resolve) =>
+ setTimeout(() => {
+ resolve(!!this.data.set(key, value));
+ }, 500)
+ );
+ }
+
+ load(key: string): Promise {
+ return new Promise((resolve, reject) =>
+ setTimeout(() => {
+ const value = this.data.get(key);
+ if (value === undefined) {
+ reject(new Error('Table not found'));
+ } else {
+ resolve({ ...(value as T) });
+ }
+ }, 500)
+ );
+ }
+}
diff --git a/src/homeworks/homework-10/DiscountService.ts b/src/homeworks/homework-10/DiscountService.ts
new file mode 100644
index 000000000..1a6c9fce9
--- /dev/null
+++ b/src/homeworks/homework-10/DiscountService.ts
@@ -0,0 +1,117 @@
+import { IDatabase } from './Database';
+import { User, Product } from './Entities';
+
+export interface IDiscountService {
+ loadDiscounts(table: string): Promise;
+ saveDiscounts(table: string): Promise;
+ getDiscount(table: string, field: string): number;
+ setDiscount(table: string, field: string, value: number): void;
+ totalDiscount(...args: number[]): number;
+}
+
+type Discount = Record;
+
+export class DiscountService implements IDiscountService {
+ private database: IDatabase;
+ private discounts: Record = {};
+ private maxDiscountValue = 100;
+ private minDiscountValue = 0;
+
+ constructor(db: IDatabase) {
+ this.database = db;
+ }
+
+ private getValidDiscount(value: number) {
+ return value < this.minDiscountValue
+ ? this.minDiscountValue
+ : value > this.maxDiscountValue
+ ? this.maxDiscountValue
+ : value;
+ }
+
+ async loadDiscounts(table: string) {
+ try {
+ this.discounts[table] = await this.database.load(table);
+ } catch (error) {
+ this.discounts[table] = {};
+ throw new Error(`Failed to load ${table} discounts`);
+ }
+ }
+
+ async saveDiscounts(table: string) {
+ try {
+ await this.database.save(table, this.discounts[table]);
+ } catch (error) {
+ console.error(`Failed to save ${table} discounts:`, error);
+ }
+ }
+
+ getDiscount(table: string, field: string) {
+ return this.discounts[table]?.[field] ?? 0;
+ }
+
+ setDiscount(table: string, field: string, value: number) {
+ if (!this.discounts[table]) {
+ this.discounts[table] = {};
+ }
+ this.discounts[table][field] = this.getValidDiscount(value);
+ }
+
+ totalDiscount(...args: number[]) {
+ return this.getValidDiscount(args.reduce((x, y) => x + y, 0));
+ }
+}
+
+abstract class BaseDiscount {
+ protected discountService: IDiscountService;
+ protected discountTable: string;
+
+ constructor(discountService: IDiscountService, discountTable: string) {
+ this.discountService = discountService;
+ this.discountTable = discountTable;
+ }
+
+ async loadDiscounts() {
+ await this.discountService.loadDiscounts(this.discountTable);
+ }
+
+ async saveDiscounts() {
+ await this.discountService.saveDiscounts(this.discountTable);
+ }
+
+ getDiscount(field: string) {
+ return this.discountService.getDiscount(this.discountTable, field);
+ }
+
+ setDiscount(field: string, value: number) {
+ this.discountService.setDiscount(this.discountTable, field, value);
+ }
+}
+
+export class UserDiscount extends BaseDiscount {
+ constructor(discountService: IDiscountService) {
+ super(discountService, 'user_discount');
+ }
+
+ getUserDiscount(user: User) {
+ return this.getDiscount(user);
+ }
+
+ setUserDiscount(user: User, value: number) {
+ this.setDiscount(user, value);
+ }
+}
+
+export class UserProductDiscount extends BaseDiscount {
+ constructor(discountService: IDiscountService) {
+ super(discountService, 'user_product_discount');
+ }
+
+ getUserProductDiscount(user: User, product: Product) {
+ return this.getDiscount(`${user}_${product}`);
+ }
+
+ setUserProductDiscount(user: User, product: Product, value: number) {
+ this.setDiscount(`${user}_${product}`, value);
+ }
+}
diff --git a/src/homeworks/homework-10/Entities.ts b/src/homeworks/homework-10/Entities.ts
new file mode 100644
index 000000000..c3d92df78
--- /dev/null
+++ b/src/homeworks/homework-10/Entities.ts
@@ -0,0 +1,12 @@
+export enum User {
+ Standard = 'Standard',
+ Premium = 'Premium',
+ Gold = 'Gold',
+ Free = 'Free',
+}
+
+export enum Product {
+ Car = 'Car',
+ Toy = 'Toy',
+ Food = 'Food',
+}
diff --git a/src/homeworks/ts1/1_base.ts b/src/homeworks/ts1/1_base.ts
new file mode 100644
index 000000000..6dd43097c
--- /dev/null
+++ b/src/homeworks/ts1/1_base.ts
@@ -0,0 +1,89 @@
+/**
+ * Нужно превратить файл в ts и указать типы аргументов и типы возвращаемого значения
+ * */
+export const removePlus = (string: string): string => string.replace(/^\+/, '');
+
+export const addPlus = (string: string): string => `+${string}`;
+
+export const removeFirstZeros = (value: string): string => value.replace(/^(-)?[0]+(-?\d+.*)$/, '$1$2');
+
+export const getBeautifulNumber = (value: number, separator = ' '): string =>
+ value?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
+
+export const round = (value: number, accuracy = 2): number => {
+ const d = 10 ** accuracy;
+ return Math.round(value * d) / d;
+};
+
+const transformRegexp =
+ /(matrix\(-?\d+(\.\d+)?, -?\d+(\.\d+)?, -?\d+(\.\d+)?, -?\d+(\.\d+)?, )(-?\d+(\.\d+)?), (-?\d+(\.\d+)?)\)/;
+
+type TransformedRegexp = {
+ x: number;
+ y: number;
+};
+
+export const getTransformFromCss = (transformCssString: string): TransformedRegexp => {
+ const data = transformCssString.match(transformRegexp);
+ if (!data) return { x: 0, y: 0 };
+ return {
+ x: parseInt(data[6], 10),
+ y: parseInt(data[8], 10),
+ };
+};
+
+type Colors = [red: number, green: number, blue: number];
+
+export const getColorContrastValue = ([red, green, blue]: Colors): number =>
+ // http://www.w3.org/TR/AERT#color-contrast
+ Math.round((red * 299 + green * 587 + blue * 114) / 1000);
+
+type BlackOrWhite = 'black' | 'white';
+
+export const getContrastType = (contrastValue: number): BlackOrWhite => (contrastValue > 125 ? 'black' : 'white');
+
+export const shortColorRegExp = /^#[0-9a-f]{3}$/i;
+export const longColorRegExp = /^#[0-9a-f]{6}$/i;
+
+export const checkColor = (color: string): void | never => {
+ if (!longColorRegExp.test(color) && !shortColorRegExp.test(color)) throw new Error(`invalid hex color: ${color}`);
+};
+
+export const hex2rgb = (color: string): Colors => {
+ checkColor(color);
+ if (shortColorRegExp.test(color)) {
+ const red = parseInt(color.substring(1, 2), 16);
+ const green = parseInt(color.substring(2, 3), 16);
+ const blue = parseInt(color.substring(3, 4), 16);
+ return [red, green, blue];
+ }
+ const red = parseInt(color.substring(1, 3), 16);
+ const green = parseInt(color.substring(3, 5), 16);
+ const blue = parseInt(color.substring(5, 8), 16);
+ return [red, green, blue];
+};
+
+type NumberedArrayItem = {
+ value: T;
+ number: number;
+};
+
+export const getNumberedArray = (arr: T[]): NumberedArrayItem[] =>
+ arr.map((value, number) => ({ value, number }));
+export const toStringArray = (arr: NumberedArrayItem[]) => arr.map(({ value, number }) => `${value}_${number}`);
+
+type Customer = {
+ id: number;
+ name: string;
+ age: number;
+ isSubscribed: boolean;
+};
+
+type TransformedCustomer = Record>;
+
+export const transformCustomers = (customers: Customer[]): TransformedCustomer => {
+ return customers.reduce((acc: TransformedCustomer, customer) => {
+ acc[customer.id] = { name: customer.name, age: customer.age, isSubscribed: customer.isSubscribed };
+ return acc;
+ }, {});
+};
diff --git a/src/homeworks/ts1/2_repair.ts b/src/homeworks/ts1/2_repair.ts
index 19e98c068..3c9b28d00 100644
--- a/src/homeworks/ts1/2_repair.ts
+++ b/src/homeworks/ts1/2_repair.ts
@@ -2,46 +2,48 @@
* Здесь код с ошибками типов. Нужно их устранить
* */
-// // Мы это не проходили, но по тексту ошибки можно понять, как это починить
-// export const getFakeApi = async (): void => {
-// const result = await fetch('https://jsonplaceholder.typicode.com/todos/1').then((response) => response.json());
-// console.log(result);
-// };
+// Мы это не проходили, но по тексту ошибки можно понять, как это починить
+export const getFakeApi = async (): Promise => {
+ const result = await fetch('https://jsonplaceholder.typicode.com/todos/1').then((response) => response.json());
+ console.log(result);
+};
//
-// // Мы это не проходили, но по тексту ошибки можно понять, как это починить
-// export class SomeClass {
-// constructor() {
-// this.set = new Set([1]);
-// this.channel = new BroadcastChannel('test-broadcast-channel');
-// }
-// }
-//
-// export type Data = {
-// type: 'Money' | 'Percent';
-// value: DataValue;
-// };
-//
-// export type DataValue = Money | Percent;
-//
-// export type Money = {
-// currency: string;
-// amount: number;
-// };
-//
-// export type Percent = {
-// percent: number;
-// };
-//
-// // Здесь, возможно, нужно использовать as, возможно в switch передавать немного по-другому
-// const getDataAmount = (data: Data): number => {
-// switch (data.type) {
-// case 'Money':
-// return data.value.amount;
-//
-// default: {
-// // eslint-disable-next-line @typescript-eslint/no-unused-vars
-// const unhandled: never = data; // здесь, возможно, нужно использовать нечто другое. :never должен остаться
-// throw new Error(`unknown type: ${data.type}`);
-// }
-// }
-// };
+// Мы это не проходили, но по тексту ошибки можно понять, как это починить
+export class SomeClass {
+ readonly set: Set;
+ readonly channel: BroadcastChannel;
+ constructor() {
+ this.set = new Set([1]);
+ this.channel = new BroadcastChannel('test-broadcast-channel');
+ }
+}
+
+export type Money = {
+ currency: string;
+ amount: number;
+};
+
+export type Percent = {
+ percent: number;
+};
+
+export type DataValue = Money | Percent;
+
+export type Data = {
+ type: 'Money' | 'Percent';
+ value: DataValue;
+};
+
+// Здесь, возможно, нужно использовать as, возможно в switch передавать немного по-другому
+const getDataAmount = (data: Data): number | never => {
+ switch (data.type) {
+ case 'Money':
+ return (data.value as Money).amount;
+
+ default: {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const unhandled: never = data as never; // здесь, возможно, нужно использовать нечто другое. :never должен остаться
+ throw new Error(`unknown type: ${data.type}`);
+ }
+ }
+};
diff --git a/src/homeworks/ts1/3_write.ts b/src/homeworks/ts1/3_write.ts
index 15f9dcdf2..f6bdf5c79 100644
--- a/src/homeworks/ts1/3_write.ts
+++ b/src/homeworks/ts1/3_write.ts
@@ -4,6 +4,102 @@
* Поэтому в идеале чтобы функции возвращали случайные данные, но в то же время не абракадабру.
* В целом сделайте так, как вам будет удобно.
* */
+import { names, photos, nouns, adjectives, bankCategories, bankOperations } from './data';
+
+type Category = {
+ id: string;
+ name: string;
+ photo?: string;
+};
+
+type Product = {
+ id: string;
+ name: string;
+ photo: string;
+ desc?: string;
+ createdAt: string;
+ oldPrice?: number;
+ price: number;
+ category: Category;
+};
+
+export type Operation = Cost | Profit;
+
+type Cost = {
+ id: string;
+ name: string;
+ desc?: string;
+ createdAt: string;
+ amount: number;
+ category: Category;
+ type: 'Cost';
+};
+
+type Profit = {
+ id: string;
+ name: string;
+ desc?: string;
+ createdAt: string;
+ amount: number;
+ category: Category;
+ type: 'Profit';
+};
+
+const getRandomItemFromArray = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
+const getRandomNumber = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min;
+const getRandomDescription = (nouns: string[], adjectives: string[]): string => {
+ const someAdjectives = [...Array(50)].map(() => getRandomItemFromArray(adjectives)).join(' ');
+ const noun = getRandomItemFromArray(nouns);
+ return `${someAdjectives} ${noun}`;
+};
+const getRandomId = () => `${getRandomNumber(1000, 9999)}-${getRandomNumber(1000, 9999)}`;
+const createRandomCategory = (): Category => ({
+ id: getRandomId(),
+ name: getRandomItemFromArray(bankCategories),
+ photo: getRandomItemFromArray(photos),
+});
+
+const createRandomCost = (createdAt: string): Cost => ({
+ id: getRandomId(),
+ name: getRandomItemFromArray(bankOperations),
+ desc: getRandomDescription(nouns, adjectives),
+ createdAt,
+ amount: getRandomNumber(-100, -1000),
+ category: createRandomCategory(),
+ type: 'Cost',
+});
+
+const createRandomProfit = (createdAt: string): Profit => ({
+ id: getRandomId(),
+ name: getRandomItemFromArray(bankOperations),
+ desc: getRandomDescription(nouns, adjectives),
+ createdAt,
+ amount: getRandomNumber(100, 1000),
+ category: createRandomCategory(),
+ type: 'Profit',
+});
+
+export const createRandomOperation = (createdAt: string): Operation => {
+ type OperationsMap = Record Operation>;
+ const operationsMap: OperationsMap = {
+ 0: createRandomCost,
+ 1: createRandomProfit,
+ };
+ const randomNumber = getRandomNumber(0, Object.keys(operationsMap).length - 1);
+
+ return operationsMap[randomNumber](createdAt);
+};
+
+export const createRandomProduct = (createdAt: string): Product => ({
+ id: getRandomId(),
+ name: getRandomItemFromArray(names),
+ photo: getRandomItemFromArray(photos),
+ desc: getRandomDescription(nouns, adjectives),
+ createdAt,
+ oldPrice: getRandomNumber(100, 1000),
+ price: getRandomNumber(100, 1000),
+ category: createRandomCategory(),
+});
/**
* Нужно создать тип Category, он будет использоваться ниже.
diff --git a/src/homeworks/ts1/data.ts b/src/homeworks/ts1/data.ts
new file mode 100644
index 000000000..fe3e440a7
--- /dev/null
+++ b/src/homeworks/ts1/data.ts
@@ -0,0 +1,522 @@
+export const bankCategories = [
+ 'Account Management',
+ 'Transactions',
+ 'Loan Services',
+ 'Credit Card Services',
+ 'Investment Services',
+ 'Online and Mobile Banking',
+ 'Customer Support',
+ 'Foreign Exchange Services',
+ 'Other Services',
+ 'Security Services',
+ 'Digital Banking',
+ 'Insurance Services',
+ 'Financial Planning',
+ 'Mortgage Services',
+ 'Business Banking Services',
+ 'ATM Services',
+];
+
+export const bankOperations = [
+ 'Open Savings Account',
+ 'Close Savings Account',
+ 'Open Checking Account',
+ 'Close Checking Account',
+ 'Open Fixed Deposit Account',
+ 'Close Fixed Deposit Account',
+ 'Open Joint Account',
+ 'Close Joint Account',
+ 'Update Account Information',
+ 'Change Account Type',
+ 'Deposit Cash',
+ 'Withdraw Cash',
+ 'Transfer Funds',
+ 'Receive Funds',
+ 'Pay Bills',
+ 'Online Payment',
+ 'Recurring Payments',
+ 'Stop Payment Request',
+ 'Issue Checks',
+ 'Cancel Checks',
+ 'Apply for Personal Loan',
+ 'Apply for Home Loan',
+ 'Apply for Auto Loan',
+ 'Apply for Student Loan',
+ 'Pay Loan Installment',
+ 'Pay Loan Off Early',
+ 'Refinance Loan',
+ 'Loan Balance Inquiry',
+ 'Loan Statement Request',
+ 'Loan Insurance',
+ 'Apply for Credit Card',
+ 'Activate Credit Card',
+ 'Block Credit Card',
+ 'Unblock Credit Card',
+ 'Increase Credit Limit',
+ 'Decrease Credit Limit',
+ 'Pay Credit Card Bill',
+ 'Credit Card Balance Inquiry',
+ 'Report Lost Credit Card',
+ 'Credit Card Statement Request',
+ 'Open Investment Account',
+ 'Close Investment Account',
+ 'Buy Stocks',
+ 'Sell Stocks',
+ 'Buy Bonds',
+ 'Sell Bonds',
+ 'Mutual Fund Investment',
+ 'Withdraw Investment',
+ 'Investment Account Statement',
+ 'Investment Advisory Services',
+ 'Register for Online Banking',
+ 'Login to Online Banking',
+ 'Reset Online Banking Password',
+ 'Mobile Banking Registration',
+ 'Mobile Banking App Download',
+ 'Mobile Check Deposit',
+ 'Online Fund Transfer',
+ 'View Account Statements',
+ 'Set Up Alerts',
+ 'Online Bill Payment',
+ 'Account Inquiry',
+ 'Transaction Dispute',
+ 'Account Lock/Unlock',
+ 'Complaint Registration',
+ 'General Banking Information',
+ 'ATM Card Issues',
+ 'Branch Locator',
+ 'Update Contact Details',
+ 'Request for Document Copies',
+ 'Fraud Alert Reporting',
+ 'Currency Exchange',
+ 'Foreign Currency Account Opening',
+ 'International Fund Transfer',
+ 'International Check Deposit',
+ 'Foreign Currency Loan',
+ 'Forex Card Application',
+ 'Forex Card Reload',
+ 'Forex Card Balance Inquiry',
+ 'Currency Rate Inquiry',
+ 'Foreign Draft Issue',
+ 'Safe Deposit Box Rental',
+ 'Safe Deposit Box Access',
+ 'Notary Services',
+ 'Document Certification',
+ 'Tax Payment Services',
+ 'Utility Bill Payments',
+ 'Property Valuation Services',
+ 'Mortgage Services',
+ 'Wealth Management Services',
+ 'Retirement Planning Services',
+ 'Activate Two-Factor Authentication',
+ 'Deactivate Two-Factor Authentication',
+ 'Change PIN/Password',
+ 'Set Up Security Questions',
+ 'Biometric Authentication Setup',
+ 'Review Security Settings',
+ 'Security Alerts & Notifications',
+ 'Anti-Money Laundering Reports',
+ 'Suspicious Activity Monitoring',
+ 'Data Privacy Requests',
+];
+
+export const names = [
+ 'Emma',
+ 'Liam',
+ 'Olivia',
+ 'Noah',
+ 'Ava',
+ 'William',
+ 'Sophia',
+ 'James',
+ 'Isabella',
+ 'Benjamin',
+ 'Mia',
+ 'Lucas',
+ 'Charlotte',
+ 'Henry',
+ 'Amelia',
+ 'Alexander',
+ 'Harper',
+ 'Michael',
+ 'Evelyn',
+ 'Ethan',
+ 'Abigail',
+ 'Daniel',
+ 'Ella',
+ 'Matthew',
+ 'Avery',
+ 'Aiden',
+ 'Scarlett',
+ 'Jackson',
+ 'Grace',
+ 'Logan',
+ 'Chloe',
+ 'David',
+ 'Victoria',
+ 'Joseph',
+ 'Riley',
+ 'Samuel',
+ 'Aria',
+ 'Sebastian',
+ 'Lily',
+ 'Jack',
+ 'Zoey',
+ 'John',
+ 'Mila',
+ 'Owen',
+ 'Layla',
+ 'Gabriel',
+ 'Nora',
+ 'Carter',
+ 'Ellie',
+ 'Luke',
+ 'Madison',
+ 'Anthony',
+ 'Hazel',
+ 'Isaac',
+ 'Aurora',
+ 'Dylan',
+ 'Penelope',
+ 'Wyatt',
+ 'Lillian',
+ 'Andrew',
+ 'Addison',
+ 'Joshua',
+ 'Lucy',
+ 'Christopher',
+ 'Hannah',
+ 'Grayson',
+ 'Stella',
+ 'Jack',
+ 'Natalie',
+ 'Julian',
+ 'Leah',
+ 'Ryan',
+ 'Violet',
+ 'Jaxon',
+ 'Savannah',
+ 'Levi',
+ 'Audrey',
+ 'Nathan',
+ 'Brooklyn',
+ 'Caleb',
+ 'Bella',
+ 'Christian',
+ 'Claire',
+ 'Hunter',
+ 'Skylar',
+ 'Eli',
+ 'Samantha',
+ 'Isaiah',
+ 'Paisley',
+ 'Thomas',
+ 'Kennedy',
+ 'Charles',
+ 'Ellie',
+ 'Aaron',
+ 'Peyton',
+ 'Lincoln',
+ 'Mila',
+ 'Adrian',
+ 'Sophie',
+ 'Jonathan',
+];
+
+export const photos = [
+ 'https://example.com/page1',
+ 'https://example.com/page2',
+ 'https://example.com/page3',
+ 'https://example.com/page4',
+ 'https://example.com/page5',
+ 'https://example.com/page6',
+ 'https://example.com/page7',
+ 'https://example.com/page8',
+ 'https://example.com/page9',
+ 'https://example.com/page10',
+ 'https://example.com/page11',
+ 'https://example.com/page12',
+ 'https://example.com/page13',
+ 'https://example.com/page14',
+ 'https://example.com/page15',
+ 'https://example.com/page16',
+ 'https://example.com/page17',
+ 'https://example.com/page18',
+ 'https://example.com/page19',
+ 'https://example.com/page20',
+ 'https://example.com/page21',
+ 'https://example.com/page22',
+ 'https://example.com/page23',
+ 'https://example.com/page24',
+ 'https://example.com/page25',
+ 'https://example.com/page26',
+ 'https://example.com/page27',
+ 'https://example.com/page28',
+ 'https://example.com/page29',
+ 'https://example.com/page30',
+ 'https://example.com/page31',
+ 'https://example.com/page32',
+ 'https://example.com/page33',
+ 'https://example.com/page34',
+ 'https://example.com/page35',
+ 'https://example.com/page36',
+ 'https://example.com/page37',
+ 'https://example.com/page38',
+ 'https://example.com/page39',
+ 'https://example.com/page40',
+ 'https://example.com/page41',
+ 'https://example.com/page42',
+ 'https://example.com/page43',
+ 'https://example.com/page44',
+ 'https://example.com/page45',
+ 'https://example.com/page46',
+ 'https://example.com/page47',
+ 'https://example.com/page48',
+ 'https://example.com/page49',
+ 'https://example.com/page50',
+ 'https://example.com/page51',
+ 'https://example.com/page52',
+ 'https://example.com/page53',
+ 'https://example.com/page54',
+ 'https://example.com/page55',
+ 'https://example.com/page56',
+ 'https://example.com/page57',
+ 'https://example.com/page58',
+ 'https://example.com/page59',
+ 'https://example.com/page60',
+ 'https://example.com/page61',
+ 'https://example.com/page62',
+ 'https://example.com/page63',
+ 'https://example.com/page64',
+ 'https://example.com/page65',
+ 'https://example.com/page66',
+ 'https://example.com/page67',
+ 'https://example.com/page68',
+ 'https://example.com/page69',
+ 'https://example.com/page70',
+ 'https://example.com/page71',
+ 'https://example.com/page72',
+ 'https://example.com/page73',
+ 'https://example.com/page74',
+ 'https://example.com/page75',
+ 'https://example.com/page76',
+ 'https://example.com/page77',
+ 'https://example.com/page78',
+ 'https://example.com/page79',
+ 'https://example.com/page80',
+ 'https://example.com/page81',
+ 'https://example.com/page82',
+ 'https://example.com/page83',
+ 'https://example.com/page84',
+ 'https://example.com/page85',
+ 'https://example.com/page86',
+ 'https://example.com/page87',
+ 'https://example.com/page88',
+ 'https://example.com/page89',
+ 'https://example.com/page90',
+ 'https://example.com/page91',
+ 'https://example.com/page92',
+ 'https://example.com/page93',
+ 'https://example.com/page94',
+ 'https://example.com/page95',
+ 'https://example.com/page96',
+ 'https://example.com/page97',
+ 'https://example.com/page98',
+ 'https://example.com/page99',
+ 'https://example.com/page100',
+];
+
+export const adjectives = [
+ 'happy',
+ 'sad',
+ 'angry',
+ 'excited',
+ 'bored',
+ 'tired',
+ 'energetic',
+ 'calm',
+ 'anxious',
+ 'brave',
+ 'curious',
+ 'daring',
+ 'eager',
+ 'fearful',
+ 'gentle',
+ 'honest',
+ 'jolly',
+ 'kind',
+ 'lazy',
+ 'mysterious',
+ 'nervous',
+ 'optimistic',
+ 'pessimistic',
+ 'quiet',
+ 'restless',
+ 'silly',
+ 'thoughtful',
+ 'upbeat',
+ 'victorious',
+ 'witty',
+ 'zealous',
+ 'adventurous',
+ 'bold',
+ 'cheerful',
+ 'determined',
+ 'elegant',
+ 'friendly',
+ 'graceful',
+ 'humble',
+ 'intelligent',
+ 'joyful',
+ 'keen',
+ 'loyal',
+ 'modest',
+ 'noble',
+ 'outgoing',
+ 'polite',
+ 'quick',
+ 'reliable',
+ 'sincere',
+ 'trustworthy',
+ 'understanding',
+ 'vibrant',
+ 'wise',
+ 'youthful',
+ 'zany',
+ 'ambitious',
+ 'brilliant',
+ 'creative',
+ 'diligent',
+ 'enthusiastic',
+ 'forgiving',
+ 'generous',
+ 'helpful',
+ 'imaginative',
+ 'jovial',
+ 'knowledgeable',
+ 'loving',
+ 'motivated',
+ 'neat',
+ 'observant',
+ 'patient',
+ 'quirky',
+ 'respectful',
+ 'supportive',
+ 'talented',
+ 'unique',
+ 'versatile',
+ 'warm',
+ 'xenial',
+ 'yielding',
+ 'zealous',
+ 'affectionate',
+ 'bright',
+ 'courageous',
+ 'dependable',
+ 'empathetic',
+ 'faithful',
+ 'gentle',
+ 'hardworking',
+ 'innovative',
+ 'jubilant',
+ 'kindhearted',
+ 'likable',
+ 'mindful',
+ 'nurturing',
+ 'optimistic',
+ 'persistent',
+ 'resourceful',
+ 'sensible',
+];
+export const nouns = [
+ 'apple',
+ 'banana',
+ 'car',
+ 'dog',
+ 'elephant',
+ 'flower',
+ 'guitar',
+ 'house',
+ 'island',
+ 'jacket',
+ 'kite',
+ 'lamp',
+ 'mountain',
+ 'notebook',
+ 'ocean',
+ 'pencil',
+ 'queen',
+ 'river',
+ 'sun',
+ 'tree',
+ 'umbrella',
+ 'vase',
+ 'whale',
+ 'xylophone',
+ 'yacht',
+ 'zebra',
+ 'airplane',
+ 'book',
+ 'cat',
+ 'door',
+ 'engine',
+ 'forest',
+ 'garden',
+ 'hat',
+ 'ice',
+ 'jungle',
+ 'key',
+ 'lion',
+ 'moon',
+ 'nest',
+ 'orange',
+ 'piano',
+ 'quilt',
+ 'road',
+ 'star',
+ 'train',
+ 'unicorn',
+ 'violin',
+ 'window',
+ 'x-ray',
+ 'yogurt',
+ 'zoo',
+ 'ant',
+ 'ball',
+ 'cake',
+ 'desk',
+ 'egg',
+ 'fish',
+ 'goat',
+ 'hill',
+ 'igloo',
+ 'jewel',
+ 'kangaroo',
+ 'leaf',
+ 'mirror',
+ 'net',
+ 'owl',
+ 'pizza',
+ 'quill',
+ 'ring',
+ 'ship',
+ 'table',
+ 'umbrella',
+ 'village',
+ 'water',
+ 'xenon',
+ 'yarn',
+ 'zinc',
+ 'arch',
+ 'bridge',
+ 'cloud',
+ 'drum',
+ 'earth',
+ 'feather',
+ 'glove',
+ 'hammer',
+ 'ink',
+ 'jigsaw',
+ 'kite',
+ 'ladder',
+];
diff --git a/src/pages/OperationForm/OperationForm.tsx b/src/pages/OperationForm/OperationForm.tsx
new file mode 100644
index 000000000..e7aa2902c
--- /dev/null
+++ b/src/pages/OperationForm/OperationForm.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { RegularForm } from '../../shared/forms/RegularForm/RegularForm';
+import { Button } from '../../shared/button/Button';
+import { FormInputField } from '../../shared/forms/FormInputField/FormInputField';
+import { FormSelectField, SelectOptionProps } from '../../shared/forms/FormSelectField/FormSelectField';
+
+import { OperationSchemaType, OperationSchema } from './operation-schema';
+
+const costOperationOption: SelectOptionProps = {
+ text: 'Cost',
+ value: 'Cost',
+};
+
+const profitOperationOption: SelectOptionProps = {
+ text: 'Profit',
+ value: 'Profit',
+};
+
+export const OperationForm = () => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ shouldUnregister: true,
+ resolver: zodResolver(OperationSchema),
+ });
+
+ const onSubmit = (data: OperationSchemaType) => {
+ console.log(data);
+ };
+
+ return (
+
+ Операция
+
+ Тип операции
+
+
+ Название
+
+
+ Категория
+
+
+ Описание
+
+
+ Дата
+
+
+ Сумма
+
+
+ Сохранить
+
+ );
+};
diff --git a/src/pages/OperationForm/operation-schema.ts b/src/pages/OperationForm/operation-schema.ts
new file mode 100644
index 000000000..b38f30be3
--- /dev/null
+++ b/src/pages/OperationForm/operation-schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+export const OperationSchema = z.object({
+ name: z.string().nonempty('Обязательное поле'),
+ desc: z.string().optional(),
+ createdAt: z.string().nonempty('Обязательное поле'),
+ amount: z.number({ invalid_type_error: 'Обязательное поле' }).positive('Сумма должна быть больше 0'),
+ category: z.string().nonempty('Обязательное поле'),
+ type: z.enum(['Cost', 'Profit']),
+});
+
+export type OperationSchemaType = z.infer;
diff --git a/src/pages/ProductForm/ProductForm.tsx b/src/pages/ProductForm/ProductForm.tsx
new file mode 100644
index 000000000..0fe98bc6b
--- /dev/null
+++ b/src/pages/ProductForm/ProductForm.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { RegularForm } from '../../shared/forms/RegularForm/RegularForm';
+import { Button } from '../../shared/button/Button';
+import { FormInputField } from '../../shared/forms/FormInputField/FormInputField';
+
+import { ProductSchema, ProductSchemaType } from './product-schema';
+
+export const ProductForm = () => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ shouldUnregister: true,
+ resolver: zodResolver(ProductSchema),
+ });
+
+ const onSubmit = (data: ProductSchemaType) => {
+ console.log(data);
+ };
+
+ return (
+
+ Товар
+
+ Название
+
+
+ Категория
+
+
+ Описание
+
+
+ Фото
+
+
+ Дата
+
+
+ Старая цена
+
+
+ Цена
+
+ Сохранить
+
+ );
+};
diff --git a/src/pages/ProductForm/product-schema.ts b/src/pages/ProductForm/product-schema.ts
new file mode 100644
index 000000000..ab217fa15
--- /dev/null
+++ b/src/pages/ProductForm/product-schema.ts
@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+export const ProductSchema = z.object({
+ name: z.string().nonempty('Обязательное поле'),
+ photo: z.string().url('Некорректный URL').nonempty('Обязательное поле'),
+ desc: z.string().optional(),
+ createdAt: z.string().nonempty('Обязательное поле'),
+ oldPrice: z
+ .number({ invalid_type_error: 'Некорректное значение, введите 0 если значение отсутстувет' })
+ .min(0, 'Сумма должна быть 0 или больше')
+ .default(0),
+ price: z.number({ invalid_type_error: 'Обязательное поле' }).positive('Сумма должна быть больше 0'),
+ category: z.string().nonempty('Обязательное поле'),
+});
+
+export type ProductSchemaType = z.infer;
diff --git a/src/pages/ProfileForm/ProfileForm.module.scss b/src/pages/ProfileForm/ProfileForm.module.scss
new file mode 100644
index 000000000..168f49fd7
--- /dev/null
+++ b/src/pages/ProfileForm/ProfileForm.module.scss
@@ -0,0 +1,5 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
\ No newline at end of file
diff --git a/src/pages/ProfileForm/ProfileForm.tsx b/src/pages/ProfileForm/ProfileForm.tsx
new file mode 100644
index 000000000..e9c5b7972
--- /dev/null
+++ b/src/pages/ProfileForm/ProfileForm.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import {
+ ChangeProfileSchema,
+ ChangeProfileSchemaType,
+ ChangePasswordSchema,
+ ChangePasswordSchemaType,
+} from './profile-schema';
+import { RegularForm } from '../../shared/forms/RegularForm/RegularForm';
+import { FormInputField } from '../../shared/forms/FormInputField/FormInputField';
+import { FormTextareaField } from '../../shared/forms/FormTextareaField/FormTextareaField';
+import { Button } from '../../shared/button/Button';
+
+import s from './ProfileForm.module.scss';
+
+const ChangeProfileForm = () => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ shouldUnregister: true,
+ resolver: zodResolver(ChangeProfileSchema),
+ });
+
+ const onSubmit = (data: ChangeProfileSchemaType) => {
+ console.log(data);
+ };
+
+ return (
+
+ Изменить профиль
+
+ Псевдоним
+
+
+ О себе
+
+ Сохранить
+
+ );
+};
+
+const ChangePasswordForm = () => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ shouldUnregister: true,
+ resolver: zodResolver(ChangePasswordSchema),
+ });
+
+ const onSubmit = (data: ChangePasswordSchemaType) => {
+ console.log(data);
+ };
+
+ return (
+
+ Изменить пароль
+
+ Пароль
+
+
+ Новый пароль
+
+
+ Повторите пароль
+
+ Изменить
+
+ );
+};
+
+export const ProfileForm = () => (
+
+
+
+
+);
diff --git a/src/pages/ProfileForm/profile-schema.ts b/src/pages/ProfileForm/profile-schema.ts
new file mode 100644
index 000000000..60c48f35c
--- /dev/null
+++ b/src/pages/ProfileForm/profile-schema.ts
@@ -0,0 +1,23 @@
+import { z } from 'zod';
+
+export const ChangeProfileSchema = z.object({
+ name: z.string().nonempty('Обязательное поле'),
+ description: z.string(),
+});
+
+export type ChangeProfileSchemaType = z.infer;
+
+const passwordZodType = z.string().nonempty('Обязательное поле').min(6, 'Слишком короткий пароль');
+
+export const ChangePasswordSchema = z
+ .object({
+ password: passwordZodType,
+ newPassword: passwordZodType,
+ confirmPassword: passwordZodType,
+ })
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: 'Пароли не совпадают',
+ path: ['confirmPassword'],
+ });
+
+export type ChangePasswordSchemaType = z.infer;
diff --git a/src/pages/SignInForm/SignInForm.tsx b/src/pages/SignInForm/SignInForm.tsx
new file mode 100644
index 000000000..bb614e6b4
--- /dev/null
+++ b/src/pages/SignInForm/SignInForm.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { RegularForm } from '../../shared/forms/RegularForm/RegularForm';
+import { Button } from '../../shared/button/Button';
+import { FormInputField } from '../../shared/forms/FormInputField/FormInputField';
+
+import { SignInSchema, SignInSchemaType } from './signin-schema';
+
+export const SignInForm = () => {
+ const {
+ reset,
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ shouldUnregister: true,
+ resolver: zodResolver(SignInSchema),
+ });
+
+ const onSubmit = (data: SignInSchemaType) => {
+ console.log(data);
+ reset();
+ };
+
+ return (
+
+ Войти
+
+ Email
+
+
+ Пароль
+
+ Войти
+
+ );
+};
diff --git a/src/pages/SignInForm/signin-schema.ts b/src/pages/SignInForm/signin-schema.ts
new file mode 100644
index 000000000..def0fa9ee
--- /dev/null
+++ b/src/pages/SignInForm/signin-schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+export const SignInSchema = z.object({
+ email: z.string().nonempty('Обязательное поле').email('Неправильный Email'),
+ password: z.string().nonempty('Обязательное поле').min(6, 'Слишком короткий пароль'),
+});
+
+export type SignInSchemaType = z.infer;
diff --git a/src/pages/SignUpForm/SignUpForm.tsx b/src/pages/SignUpForm/SignUpForm.tsx
new file mode 100644
index 000000000..64676cf7f
--- /dev/null
+++ b/src/pages/SignUpForm/SignUpForm.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { RegularForm } from '../../shared/forms/RegularForm/RegularForm';
+import { Button } from '../../shared/button/Button';
+import { FormInputField } from '../../shared/forms/FormInputField/FormInputField';
+
+import { SignUpSchemaType, SignUpSchema } from './signup-schema';
+
+export const SignUpForm = () => {
+ const {
+ reset,
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ shouldUnregister: true,
+ resolver: zodResolver(SignUpSchema),
+ });
+
+ const onSubmit = (data: SignUpSchemaType) => {
+ console.log(data);
+ reset();
+ };
+
+ return (
+
+ Зарегистрироваться
+
+ Email
+
+
+ Пароль
+
+ Зарегистрироваться
+
+ );
+};
diff --git a/src/pages/SignUpForm/signup-schema.ts b/src/pages/SignUpForm/signup-schema.ts
new file mode 100644
index 000000000..210942b9c
--- /dev/null
+++ b/src/pages/SignUpForm/signup-schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+export const SignUpSchema = z.object({
+ email: z.string().nonempty('Обязательное поле').email('Неправильный Email'),
+ password: z.string().nonempty('Обязательное поле').min(6, 'Слишком короткий пароль'),
+});
+
+export type SignUpSchemaType = z.infer;
diff --git a/src/shared/button/Button.modules.scss b/src/shared/button/Button.modules.scss
new file mode 100644
index 000000000..367b092c6
--- /dev/null
+++ b/src/shared/button/Button.modules.scss
@@ -0,0 +1,20 @@
+.button {
+ padding: 10px 15px;
+ font-size: 1em;
+ color: #fff;
+ background-color: #007bff;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+
+ &:hover {
+ &:not([disabled]) {
+ background-color: #0056b3;
+ }
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+}
diff --git a/src/shared/button/Button.tsx b/src/shared/button/Button.tsx
index 7548da044..9125072ea 100644
--- a/src/shared/button/Button.tsx
+++ b/src/shared/button/Button.tsx
@@ -1,34 +1,15 @@
-import React, { FC } from 'react';
-import cn from 'clsx';
-import { sum } from './sum';
-import './button.css';
-
-interface ButtonProps {
- primary?: boolean;
- backgroundColor?: string | null;
- size?: string;
- label: string;
-}
-/**
- * Primary UI component for user interaction
- */
-
-export const Button: FC = ({ primary, backgroundColor, size, label, ...props }) => {
- const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
-
- const onClick = () => {
- sum(4, 5);
- };
-
- return (
-
- {label}
-
- );
+import React, { ReactNode, MouseEvent } from 'react';
+import s from './Button.modules.scss';
+
+type ButtonProps = {
+ children: ReactNode;
+ type?: 'submit' | 'reset' | 'button';
+ disabled?: boolean;
+ onClick?: (event: MouseEvent) => void;
};
+
+export const Button = ({ children, onClick, type, disabled = false }: ButtonProps) => (
+
+ {children}
+
+);
diff --git a/src/shared/button/button.css b/src/shared/button/button.css
deleted file mode 100644
index 61c9c11f3..000000000
--- a/src/shared/button/button.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.storybook-button {
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
- font-weight: 700;
- border: 0;
- border-radius: 3em;
- cursor: pointer;
- display: inline-block;
- line-height: 1;
-}
-.storybook-button--primary {
- color: white;
- background-color: #1ea7fd;
-}
-.storybook-button--secondary {
- color: #333;
- background-color: transparent;
- box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
-}
-.storybook-button--small {
- font-size: 12px;
- padding: 10px 16px;
-}
-.storybook-button--medium {
- font-size: 14px;
- padding: 11px 20px;
-}
-.storybook-button--large {
- font-size: 16px;
- padding: 12px 24px;
-}
diff --git a/src/shared/button/sum.test.ts b/src/shared/button/sum.test.ts
deleted file mode 100644
index b0c67eb9b..000000000
--- a/src/shared/button/sum.test.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { sum } from './sum';
-
-test('adds 4 + 5 to equal 9', () => {
- expect(sum(4, 5)).toBe(9);
-});
diff --git a/src/shared/button/sum.ts b/src/shared/button/sum.ts
deleted file mode 100644
index 8073d27cc..000000000
--- a/src/shared/button/sum.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const sum = (a: number, b: number): number => a + b;
diff --git a/src/shared/collapse/Collapse.module.scss b/src/shared/collapse/Collapse.module.scss
new file mode 100644
index 000000000..c12dfaa73
--- /dev/null
+++ b/src/shared/collapse/Collapse.module.scss
@@ -0,0 +1,27 @@
+.collapse-container {
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ margin: 10px 0;
+ overflow: hidden;
+}
+
+.collapse-button {
+ width: 100%;
+ background-color: #007bff;
+ color: white;
+ padding: 10px;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ font-size: 1em;
+
+ &:hover {
+ background-color: #0056b3;
+ }
+}
+
+.collapse-content {
+ padding: 10px;
+ background-color: #f9f9f9;
+ transition: height 0.3s ease;
+}
diff --git a/src/shared/collapse/Collapse.tsx b/src/shared/collapse/Collapse.tsx
new file mode 100644
index 000000000..da893a0b2
--- /dev/null
+++ b/src/shared/collapse/Collapse.tsx
@@ -0,0 +1,36 @@
+import React, { useState, ReactNode } from 'react';
+import { useCollapseHeight } from './hooks/useCollapseHeight';
+import s from './Collapse.module.scss';
+
+type CollapseProps = {
+ title: string;
+ children: ReactNode;
+};
+
+export const Collapse: React.FC = ({ title, children }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { height, contentRef } = useCollapseHeight();
+
+ const toggleCollapse = () => {
+ setIsOpen((prev) => !prev);
+ };
+
+ return (
+
+ );
+};
diff --git a/src/shared/collapse/hooks/useCollapseHeight.ts b/src/shared/collapse/hooks/useCollapseHeight.ts
new file mode 100644
index 000000000..4c4fb237c
--- /dev/null
+++ b/src/shared/collapse/hooks/useCollapseHeight.ts
@@ -0,0 +1,26 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+export const useCollapseHeight = () => {
+ const [height, setHeight] = useState(0);
+ const contentRef = useRef(null);
+
+ useEffect(() => {
+ const resizeObserver = new ResizeObserver((entries) => {
+ entries.forEach((entry) => setHeight(entry.borderBoxSize[0].blockSize));
+ });
+
+ const currentContentRef = contentRef.current;
+
+ if (currentContentRef) {
+ resizeObserver.observe(currentContentRef);
+ }
+
+ return () => {
+ if (currentContentRef) {
+ resizeObserver.unobserve(currentContentRef);
+ }
+ };
+ }, []);
+
+ return { height, contentRef };
+};
diff --git a/src/shared/description/Description.module.scss b/src/shared/description/Description.module.scss
new file mode 100644
index 000000000..3fe4f8011
--- /dev/null
+++ b/src/shared/description/Description.module.scss
@@ -0,0 +1,11 @@
+.description {
+ font-size: 1em;
+ color: #555;
+ margin-top: 10px;
+}
+
+.short {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/src/shared/description/Description.tsx b/src/shared/description/Description.tsx
new file mode 100644
index 000000000..2ed14ecaa
--- /dev/null
+++ b/src/shared/description/Description.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import cn from 'clsx';
+import s from './Description.module.scss';
+
+type DescriptionProps = {
+ description: string;
+ isShort?: boolean;
+};
+
+export const Description = ({ description, isShort = false }: DescriptionProps) => (
+ {description}
+);
diff --git a/src/shared/forms/FormInputField/FormInputField.module.scss b/src/shared/forms/FormInputField/FormInputField.module.scss
new file mode 100644
index 000000000..8f214f602
--- /dev/null
+++ b/src/shared/forms/FormInputField/FormInputField.module.scss
@@ -0,0 +1,26 @@
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+.input {
+ padding: 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+
+ &.error {
+ border-color: red;
+ }
+}
+
+.error-message {
+ color: red;
+ font-size: 0.875rem;
+ padding-top: 0.3rem;
+}
diff --git a/src/shared/forms/FormInputField/FormInputField.tsx b/src/shared/forms/FormInputField/FormInputField.tsx
new file mode 100644
index 000000000..a8d4ecd3c
--- /dev/null
+++ b/src/shared/forms/FormInputField/FormInputField.tsx
@@ -0,0 +1,32 @@
+import React, { ReactNode } from 'react';
+import { FieldError, UseFormRegister, Path, FieldValues } from 'react-hook-form';
+import cn from 'clsx';
+import s from './FormInputField.module.scss';
+
+type FormInputFieldProps = {
+ children: ReactNode;
+ type: string;
+ register: UseFormRegister;
+ name: Path;
+ isNumber?: boolean;
+ errors?: FieldError;
+};
+
+export const FormInputField = ({
+ children,
+ type,
+ register,
+ isNumber,
+ name,
+ errors,
+}: FormInputFieldProps) => (
+
+ {children}
+
+ {errors && {errors.message} }
+
+);
diff --git a/src/shared/forms/FormSelectField/FormSelectField.module.scss b/src/shared/forms/FormSelectField/FormSelectField.module.scss
new file mode 100644
index 000000000..2b15fe113
--- /dev/null
+++ b/src/shared/forms/FormSelectField/FormSelectField.module.scss
@@ -0,0 +1,26 @@
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+.select {
+ padding: 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+
+ &.error {
+ border-color: red;
+ }
+}
+
+.error-message {
+ color: red;
+ font-size: 0.875rem;
+ padding-top: 0.3rem;
+}
diff --git a/src/shared/forms/FormSelectField/FormSelectField.tsx b/src/shared/forms/FormSelectField/FormSelectField.tsx
new file mode 100644
index 000000000..ef5184ae1
--- /dev/null
+++ b/src/shared/forms/FormSelectField/FormSelectField.tsx
@@ -0,0 +1,37 @@
+import React, { ReactNode } from 'react';
+import { FieldError, UseFormRegister, Path, FieldValues } from 'react-hook-form';
+import cn from 'clsx';
+import s from './FormSelectField.module.scss';
+
+export type SelectOptionProps = {
+ text: string;
+ value: string;
+};
+
+const SelectOption = ({ text, value }: SelectOptionProps) => {text} ;
+
+type FormSelectFieldProps = {
+ children: ReactNode;
+ register: UseFormRegister;
+ options: SelectOptionProps[];
+ name: Path;
+ errors?: FieldError;
+};
+
+export const FormSelectField = ({
+ children,
+ register,
+ options,
+ name,
+ errors,
+}: FormSelectFieldProps) => (
+
+ {children}
+
+ {options.map((option, index) => (
+
+ ))}
+
+ {errors && {errors.message} }
+
+);
diff --git a/src/shared/forms/FormTextareaField/FormTextareaField.module.scss b/src/shared/forms/FormTextareaField/FormTextareaField.module.scss
new file mode 100644
index 000000000..8568d3e8b
--- /dev/null
+++ b/src/shared/forms/FormTextareaField/FormTextareaField.module.scss
@@ -0,0 +1,27 @@
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+.textarea {
+ padding: 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ resize: vertical;
+
+ &.error {
+ border-color: red;
+ }
+}
+
+.error-message {
+ color: red;
+ font-size: 0.875rem;
+ padding-top: 0.3rem;
+}
diff --git a/src/shared/forms/FormTextareaField/FormTextareaField.tsx b/src/shared/forms/FormTextareaField/FormTextareaField.tsx
new file mode 100644
index 000000000..b899037e6
--- /dev/null
+++ b/src/shared/forms/FormTextareaField/FormTextareaField.tsx
@@ -0,0 +1,26 @@
+import React, { ReactNode } from 'react';
+import { FieldError, UseFormRegister, Path, FieldValues } from 'react-hook-form';
+import cn from 'clsx';
+import s from './FormTextareaField.module.scss';
+
+type FormTextareaFieldProps = {
+ children: ReactNode;
+ register: UseFormRegister;
+ name: Path;
+ rows?: number;
+ errors?: FieldError;
+};
+
+export const FormTextareaField = ({
+ children,
+ rows,
+ register,
+ name,
+ errors,
+}: FormTextareaFieldProps) => (
+
+ {children}
+
+ {errors && {errors.message} }
+
+);
diff --git a/src/shared/forms/RegularForm/RegularForm.module.scss b/src/shared/forms/RegularForm/RegularForm.module.scss
new file mode 100644
index 000000000..aa7703b13
--- /dev/null
+++ b/src/shared/forms/RegularForm/RegularForm.module.scss
@@ -0,0 +1,14 @@
+.form {
+ max-width: 400px;
+ padding: 1rem;
+ border: 1px solid #ccc;
+ border-radius: 8px;
+ background-color: #f9f9f9;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.title {
+ text-align: center;
+}
diff --git a/src/shared/forms/RegularForm/RegularForm.tsx b/src/shared/forms/RegularForm/RegularForm.tsx
new file mode 100644
index 000000000..586a45019
--- /dev/null
+++ b/src/shared/forms/RegularForm/RegularForm.tsx
@@ -0,0 +1,21 @@
+import React, { ReactNode, FormEventHandler } from 'react';
+import s from './RegularForm.module.scss';
+
+type RegularFormProps = {
+ onSubmit: FormEventHandler;
+ children: ReactNode;
+};
+
+const RegularForm = ({ onSubmit, children }: RegularFormProps) => {
+ return (
+
+ );
+};
+
+const Title = ({ children }: { children: ReactNode }) => {children} ;
+
+RegularForm.Title = Title;
+
+export { RegularForm };
diff --git a/src/shared/header/Header.module.scss b/src/shared/header/Header.module.scss
new file mode 100644
index 000000000..84cbf3220
--- /dev/null
+++ b/src/shared/header/Header.module.scss
@@ -0,0 +1,23 @@
+.header {
+ display: flex;
+ justify-content: space-between;
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+ background-color: var(--header-bg-color);
+ color: white;
+ padding: 10px;
+ min-height: 50px;
+ z-index: 1000;
+}
+
+.left {
+ display: flex;
+ gap: 10px;
+}
+
+.right {
+ display: flex;
+ justify-content: end;
+ gap: 10px;
+}
diff --git a/src/shared/header/Header.tsx b/src/shared/header/Header.tsx
new file mode 100644
index 000000000..658f18145
--- /dev/null
+++ b/src/shared/header/Header.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { Logo } from '../logo/Logo';
+import { ThemeSwitcher } from '../theme-switcher/ThemeSwitcher';
+import { LangSwitcher } from '../lang-switcher/LangSwitcher';
+import s from './Header.module.scss';
+
+export type HeaderProps = {
+ showLogo: boolean;
+};
+
+export const Header = ({ showLogo = true }: HeaderProps) => (
+
+
{showLogo && }
+
+
+
+
+
+);
diff --git a/src/shared/i-shop/add-to-cart/AddToCart.module.scss b/src/shared/i-shop/add-to-cart/AddToCart.module.scss
new file mode 100644
index 000000000..bdf6bce42
--- /dev/null
+++ b/src/shared/i-shop/add-to-cart/AddToCart.module.scss
@@ -0,0 +1,14 @@
+.quantity-container {
+ display: flex;
+ align-items: center;
+}
+
+.quantity-input {
+ width: 50px;
+ font-size: 1em;
+ text-align: center;
+ border: 1px solid #007bff;
+ border-radius: 5px;
+ margin: 0 5px;
+ padding: 5px;
+}
diff --git a/src/shared/i-shop/add-to-cart/AddToCart.tsx b/src/shared/i-shop/add-to-cart/AddToCart.tsx
new file mode 100644
index 000000000..aa4e60b52
--- /dev/null
+++ b/src/shared/i-shop/add-to-cart/AddToCart.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { Button } from '../../button/Button';
+import s from './AddToCart.module.scss';
+
+type AddToCartProps = {
+ quantity: number;
+};
+
+const AddToCartEdit = ({ quantity }: AddToCartProps) => (
+
+ -
+
+ +
+
+);
+
+export const AddToCart = ({ quantity }: AddToCartProps) =>
+ quantity > 0 ? : В корзину ;
diff --git a/src/shared/i-shop/product-cart/ProductCart.module.scss b/src/shared/i-shop/product-cart/ProductCart.module.scss
new file mode 100644
index 000000000..eebdefd22
--- /dev/null
+++ b/src/shared/i-shop/product-cart/ProductCart.module.scss
@@ -0,0 +1,11 @@
+.product-cart {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+ width: 600px;
+ padding: 16px;
+}
diff --git a/src/shared/i-shop/product-cart/ProductCart.tsx b/src/shared/i-shop/product-cart/ProductCart.tsx
new file mode 100644
index 000000000..5e7543def
--- /dev/null
+++ b/src/shared/i-shop/product-cart/ProductCart.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Button } from '../../button/Button';
+import { Product, ProductProps } from '../product/Product';
+import s from './ProductCart.module.scss';
+
+const DeleteButton = () => Удалить ;
+
+type ProductCartProps = {
+ product: ProductProps;
+};
+
+export const ProductCart = ({ product }: ProductCartProps) => (
+
+);
diff --git a/src/shared/i-shop/product-container/ProductContainer.module.scss b/src/shared/i-shop/product-container/ProductContainer.module.scss
new file mode 100644
index 000000000..062ed1bb7
--- /dev/null
+++ b/src/shared/i-shop/product-container/ProductContainer.module.scss
@@ -0,0 +1,9 @@
+.product-container {
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+ width: 300px;
+ padding: 16px;
+ text-align: center;
+}
diff --git a/src/shared/i-shop/product-container/ProductContainer.tsx b/src/shared/i-shop/product-container/ProductContainer.tsx
new file mode 100644
index 000000000..74fabe7d8
--- /dev/null
+++ b/src/shared/i-shop/product-container/ProductContainer.tsx
@@ -0,0 +1,10 @@
+import React, { ReactNode } from 'react';
+import s from './ProductContainer.module.scss';
+
+type ProductContainerProps = {
+ children: ReactNode;
+};
+
+export const ProductContainer = ({ children }: ProductContainerProps) => (
+ {children}
+);
diff --git a/src/shared/i-shop/product-detail/ProductDetail.module.scss b/src/shared/i-shop/product-detail/ProductDetail.module.scss
new file mode 100644
index 000000000..eb47a1a35
--- /dev/null
+++ b/src/shared/i-shop/product-detail/ProductDetail.module.scss
@@ -0,0 +1,13 @@
+.product-category {
+ font-size: 1.2em;
+ color: #888;
+ margin-bottom: 8px;
+}
+
+.product-images {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 8px;
+ margin-bottom: 16px;
+}
diff --git a/src/shared/i-shop/product-detail/ProductDetail.tsx b/src/shared/i-shop/product-detail/ProductDetail.tsx
new file mode 100644
index 000000000..09aadb4fd
--- /dev/null
+++ b/src/shared/i-shop/product-detail/ProductDetail.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { Description } from '../../description/Description';
+import { AddToCart } from '../add-to-cart/AddToCart';
+import { Image } from '../../image/Image';
+import { MT15 } from '../../mt15/MT15';
+import { ProductContainer } from '../product-container/ProductContainer';
+import { Product, ProductProps } from '../product/Product';
+import s from './ProductDetail.module.scss';
+
+type ProductDetailProps = ProductProps & {
+ images: string[];
+ category: string;
+ description: string;
+};
+
+export const ProductDetail = (data: ProductDetailProps) => (
+
+ {data.category}
+
+ {data.images.map((image, index) => (
+
+ ))}
+
+
+
+
+
+
+
+);
diff --git a/src/shared/i-shop/product-summary/ProductSummary.tsx b/src/shared/i-shop/product-summary/ProductSummary.tsx
new file mode 100644
index 000000000..b00f02345
--- /dev/null
+++ b/src/shared/i-shop/product-summary/ProductSummary.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Image } from '../../image/Image';
+import { MT15 } from '../../mt15/MT15';
+import { Description } from '../../description/Description';
+import { ProductContainer } from '../product-container/ProductContainer';
+import { Product, ProductProps } from '../product/Product';
+import { AddToCart } from '../add-to-cart/AddToCart';
+
+type ProductSummaryProps = ProductProps & {
+ image: string;
+ shortDescription: string;
+};
+
+export const ProductSummary = (data: ProductSummaryProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/src/shared/i-shop/product/Product.module.scss b/src/shared/i-shop/product/Product.module.scss
new file mode 100644
index 000000000..73437aa8b
--- /dev/null
+++ b/src/shared/i-shop/product/Product.module.scss
@@ -0,0 +1,10 @@
+.product-title {
+ font-size: 1.5em;
+ margin: 16px 0;
+}
+
+.product-price {
+ color: #e91e63;
+ font-size: 1.2em;
+ margin: 8px 0;
+}
diff --git a/src/shared/i-shop/product/Product.tsx b/src/shared/i-shop/product/Product.tsx
new file mode 100644
index 000000000..17cd02a32
--- /dev/null
+++ b/src/shared/i-shop/product/Product.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import s from './Product.module.scss';
+
+export type ProductProps = {
+ title: string;
+ price: number;
+};
+
+export const Product = (data: ProductProps) => (
+ <>
+ {data.title}
+ {`Цена: ${data.price} ₽`}
+ >
+);
diff --git a/src/shared/icome-expenses-accounting/operation-container/OperationContainer.module.scss b/src/shared/icome-expenses-accounting/operation-container/OperationContainer.module.scss
new file mode 100644
index 000000000..6a14ec792
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-container/OperationContainer.module.scss
@@ -0,0 +1,7 @@
+.operation-container {
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ padding: 15px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
diff --git a/src/shared/icome-expenses-accounting/operation-container/OperationContainer.tsx b/src/shared/icome-expenses-accounting/operation-container/OperationContainer.tsx
new file mode 100644
index 000000000..0cb6356bb
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-container/OperationContainer.tsx
@@ -0,0 +1,10 @@
+import React, { ReactNode } from 'react';
+import s from './OperationContainer.module.scss';
+
+type OperationContainerProps = {
+ children: ReactNode;
+};
+
+export const OperationContainer = ({ children }: OperationContainerProps) => (
+ {children}
+);
diff --git a/src/shared/icome-expenses-accounting/operation-detail/OperationDetail.module.scss b/src/shared/icome-expenses-accounting/operation-detail/OperationDetail.module.scss
new file mode 100644
index 000000000..c5947c57f
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-detail/OperationDetail.module.scss
@@ -0,0 +1,5 @@
+.date {
+ font-size: 0.9em;
+ color: #999;
+ margin-top: 10px;
+}
diff --git a/src/shared/icome-expenses-accounting/operation-detail/OperationDetail.tsx b/src/shared/icome-expenses-accounting/operation-detail/OperationDetail.tsx
new file mode 100644
index 000000000..527a96bfe
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-detail/OperationDetail.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Description } from '../../description/Description';
+import { Button } from '../../button/Button';
+import { MT15 } from '../../mt15/MT15';
+import { OperationContainer } from '../operation-container/OperationContainer';
+import { Operation, OperationProps } from '../operation/Operation';
+import s from './OperationDetail.module.scss';
+
+const EditButton = () => (
+
+ Редактировать
+
+);
+
+type OperationDetailProps = OperationProps & { description: string; date: string };
+
+export const OperationDetail = (data: OperationDetailProps) => (
+
+
+
+ {data.date}
+
+
+);
diff --git a/src/shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList.module.scss b/src/shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList.module.scss
new file mode 100644
index 000000000..0994f9c57
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList.module.scss
@@ -0,0 +1,5 @@
+.operation-dynamic-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
\ No newline at end of file
diff --git a/src/shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList.tsx b/src/shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList.tsx
new file mode 100644
index 000000000..a724198a4
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList.tsx
@@ -0,0 +1,26 @@
+import React, { useCallback, useRef, useState } from 'react';
+import { OperationList } from '../operation-list/OperationList';
+import { createRandomOperation, Operation } from '../../../homeworks/ts1/3_write';
+import s from './OperationDynamicList.module.scss';
+
+const createOpearation = () => createRandomOperation(new Date().toLocaleDateString());
+const createBatchOperations = (nr: number) => Array.from(Array(nr), () => createOpearation());
+
+export const OperationDynamicList = () => {
+ const [operations, setOperations] = useState(createBatchOperations(5));
+ const addMoreOperations = () => setOperations((prev) => [...prev, ...createBatchOperations(5)]);
+ const observer = useRef(null);
+
+ const lastOperationRef = useCallback((node: HTMLDivElement) => {
+ observer.current && observer.current.disconnect();
+ observer.current = new IntersectionObserver((items) => items[0].isIntersecting && addMoreOperations());
+ node && observer.current.observe(node);
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/src/shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore.module.scss b/src/shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore.module.scss
new file mode 100644
index 000000000..da70d5209
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore.module.scss
@@ -0,0 +1,5 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
\ No newline at end of file
diff --git a/src/shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore.tsx b/src/shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore.tsx
new file mode 100644
index 000000000..885108573
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore.tsx
@@ -0,0 +1,20 @@
+import React, { useState } from 'react';
+import { OperationList } from '../operation-list/OperationList';
+import { Button } from '../../button/Button';
+import { createRandomOperation, Operation } from '../../../homeworks/ts1/3_write';
+import s from './OperationListAddMore.module.scss';
+
+const createOpearation = () => createRandomOperation(new Date().toLocaleDateString());
+const createBatchOperations = (nr: number) => Array.from(Array(nr), () => createOpearation());
+
+export const OperationListAddMore = () => {
+ const [operations, setOperations] = useState(createBatchOperations(2));
+ const addMoreOperations = () => setOperations((prev) => [...prev, ...createBatchOperations(2)]);
+
+ return (
+
+
+ Показать еще
+
+ );
+};
diff --git a/src/shared/icome-expenses-accounting/operation-list/OperationList.module.scss b/src/shared/icome-expenses-accounting/operation-list/OperationList.module.scss
new file mode 100644
index 000000000..bf390d6dd
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-list/OperationList.module.scss
@@ -0,0 +1,5 @@
+.operation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
\ No newline at end of file
diff --git a/src/shared/icome-expenses-accounting/operation-list/OperationList.tsx b/src/shared/icome-expenses-accounting/operation-list/OperationList.tsx
new file mode 100644
index 000000000..c89d1aea6
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-list/OperationList.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { OperationDetail } from '../operation-detail/OperationDetail';
+import { Operation as OperationProps } from '../../../homeworks/ts1/3_write';
+import s from './OperationList.module.scss';
+
+type OperationItemProps = Omit;
+
+const OperationItem = (data: OperationItemProps) => (
+
+);
+
+type OperationListProps = {
+ items: OperationProps[];
+};
+
+export const OperationList = ({ items }: OperationListProps) => (
+
+ {items.map((item) => {
+ const { id, ...rest } = item;
+ return ;
+ })}
+
+);
diff --git a/src/shared/icome-expenses-accounting/operation-summary/OperationSummary.tsx b/src/shared/icome-expenses-accounting/operation-summary/OperationSummary.tsx
new file mode 100644
index 000000000..a464f6513
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation-summary/OperationSummary.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Description } from '../../description/Description';
+import { OperationContainer } from '../operation-container/OperationContainer';
+import { Operation, OperationProps } from '../operation/Operation';
+
+type OperationSummaryProps = OperationProps & {
+ shortDescription: string;
+};
+
+export const OperationSummary = (data: OperationSummaryProps) => (
+
+
+
+
+);
diff --git a/src/shared/icome-expenses-accounting/operation/Operation.module.scss b/src/shared/icome-expenses-accounting/operation/Operation.module.scss
new file mode 100644
index 000000000..c2137bcfa
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation/Operation.module.scss
@@ -0,0 +1,25 @@
+.amount {
+ font-size: 1.5em;
+ font-weight: bold;
+ color: #4caf50;
+
+ &::before {
+ content: '₽';
+ }
+}
+
+.category {
+ font-size: 1.2em;
+ color: #888;
+ margin-top: 5px;
+}
+
+.title {
+ font-size: 1.3em;
+ font-weight: bold;
+ margin-top: 10px;
+}
+
+.cost {
+ color: crimson;
+}
diff --git a/src/shared/icome-expenses-accounting/operation/Operation.tsx b/src/shared/icome-expenses-accounting/operation/Operation.tsx
new file mode 100644
index 000000000..f5ee24653
--- /dev/null
+++ b/src/shared/icome-expenses-accounting/operation/Operation.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import cn from 'clsx';
+import s from './Operation.module.scss';
+
+export type OperationProps = {
+ amount: number;
+ category: string;
+ title: string;
+};
+
+export const Operation = (data: OperationProps) => (
+ <>
+ {data.amount}
+ {data.category}
+ {data.title}
+ >
+);
diff --git a/src/shared/image/Image.module.scss b/src/shared/image/Image.module.scss
new file mode 100644
index 000000000..bea29d9ac
--- /dev/null
+++ b/src/shared/image/Image.module.scss
@@ -0,0 +1,5 @@
+.image {
+ width: 100%;
+ height: auto;
+ border-radius: 8px;
+}
diff --git a/src/shared/image/Image.tsx b/src/shared/image/Image.tsx
new file mode 100644
index 000000000..9e068027d
--- /dev/null
+++ b/src/shared/image/Image.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import s from './Image.module.scss';
+
+type ImageProps = {
+ url: string;
+ title: string;
+};
+
+export const Image = ({ url, title }: ImageProps) => ;
diff --git a/src/shared/lang-switcher/LangSwitcher.tsx b/src/shared/lang-switcher/LangSwitcher.tsx
new file mode 100644
index 000000000..65538061e
--- /dev/null
+++ b/src/shared/lang-switcher/LangSwitcher.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { useLang } from '../providers/LangProvider';
+import { Button } from '../button/Button';
+
+export const LangSwitcher = () => {
+ const { lang, toggleLang } = useLang();
+
+ return Switch to {lang === 'ru' ? 'English' : 'Russian'} lang ;
+};
diff --git a/src/shared/layout/Layout.module.scss b/src/shared/layout/Layout.module.scss
new file mode 100644
index 000000000..9d2e007cd
--- /dev/null
+++ b/src/shared/layout/Layout.module.scss
@@ -0,0 +1,6 @@
+.content {
+ margin: 0;
+ padding: 20px;
+ background-color: var(--layout-bg-color);
+ color: var(--color);
+}
diff --git a/src/shared/layout/Layout.tsx b/src/shared/layout/Layout.tsx
new file mode 100644
index 000000000..08df235a1
--- /dev/null
+++ b/src/shared/layout/Layout.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Header } from '../header/Header';
+import { Content, useLang } from '../../shared/providers/LangProvider';
+import s from './Layout.module.scss';
+
+const content: Content = {
+ text: {
+ en: `As the sun began to set over the tranquil village of Willowbrook, a sense of anticipation filled the air. The annual harvest festival was about to commence, and the villagers were bustling with excitement. Children ran through the fields, their laughter echoing like a melody of pure joy. Stalls were being set up along the main square, offering an array of handmade crafts, freshly baked goods, and the season’s finest produce.
+At the heart of the village stood an ancient oak tree, its branches sprawling wide like a guardian of time. Beneath this tree, the elders gathered, sharing stories of yore and wisdom passed down through generations. Amelia, a young girl with sparkling eyes and boundless curiosity, sat among them, soaking in every word. She loved listening to tales of bravery, love, and adventure, dreaming that one day, she too would have stories to tell.
+As the evening unfolded, the sky painted itself in hues of orange, pink, and purple, casting a magical glow over Willowbrook. The village musicians took their place, their instruments gleaming in the fading light. The first notes of a lively tune filled the air, and soon, everyone was dancing. Amelia twirled and spun, her laughter blending with the music, her heart filled with the simple happiness that only such moments can bring.
+As the night grew darker, lanterns were lit, casting a warm, golden light over the festivities. The aroma of roasted chestnuts and spiced cider wafted through the air, inviting everyone to partake in the feast. Friends and families gathered around long wooden tables, sharing food and stories, creating memories that would last a lifetime.
+Amidst the joy and celebration, Amelia’s thoughts wandered to the future. She knew that as long as the spirit of the harvest festival lived on, the bond within the community would remain strong, and Willowbrook would continue to be a place of love, laughter, and unity. With a heart full of hope and dreams, she smiled, knowing that she was part of something truly special.`,
+ ru: `Когда солнце начало садиться над тихой деревушкой Виллоубрук, в воздухе витало чувство ожидания. Ежегодный праздник урожая вот-вот начнется, и жители деревни бурлили от волнения. Дети бегали по полям, их смех звучал как мелодия чистой радости. На главной площади устанавливались ларьки, предлагающие ассортимент самодельных поделок, свежевыпеченных товаров и лучших продуктов сезона.
+В центре деревни стоял древний дуб, его ветви раскидывались широко, как хранитель времени. Под этим деревом собирались старейшины, рассказывая истории былых времен и мудрость, переданную через поколения. Амелия, молодая девушка с блестящими глазами и безграничным любопытством, сидела среди них, впитывая каждое слово. Она любила слушать истории о храбрости, любви и приключениях, мечтая о том, что однажды у нее тоже будут свои истории.
+По мере того как вечер разворачивался, небо окрашивалось в оттенки оранжевого, розового и пурпурного, придавая Виллоубруку магическое сияние. Деревенские музыканты заняли свои места, их инструменты сияли в ускользающем свете. Первые ноты оживленной мелодии заполнили воздух, и вскоре все танцевали. Амелия кружилась и вертелась, ее смех сливался с музыкой, сердце наполнялось простой радостью, которую могут принести только такие моменты.
+Когда ночь стала темнее, зажглись фонари, отбрасывая теплый, золотой свет на праздник. Аромат жареных каштанов и пряного сидра витал в воздухе, приглашая всех принять участие в пиршестве. Друзья и семьи собрались вокруг длинных деревянных столов, делясь едой и историями, создавая воспоминания, которые останутся на всю жизнь.
+Среди радости и праздника мысли Амелии унеслись в будущее. Она знала, что пока живет дух праздника урожая, связь внутри сообщества останется сильной, и Виллоубрук продолжит быть местом любви, смеха и единства. С сердцем, полным надежд и мечтаний, она улыбалась, зная, что является частью чего-то по-настоящему особенного.`,
+ },
+};
+
+const TestContent = () => {
+ const { useContent } = useLang();
+ const l = useContent(content);
+
+ return {l('text')}
;
+};
+
+export const Layout = () => (
+ <>
+
+
+
+
+
+ >
+);
diff --git a/src/shared/logo/Logo.module.scss b/src/shared/logo/Logo.module.scss
new file mode 100644
index 000000000..2b11759ff
--- /dev/null
+++ b/src/shared/logo/Logo.module.scss
@@ -0,0 +1,13 @@
+.logo {
+ width: 80px;
+ height: 50px;
+ background-color: var(--logo-bg-color);
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: var(--logo-text-color);
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 1.35rem;
+ font-weight: bold;
+}
diff --git a/src/shared/logo/Logo.tsx b/src/shared/logo/Logo.tsx
new file mode 100644
index 000000000..5552aa778
--- /dev/null
+++ b/src/shared/logo/Logo.tsx
@@ -0,0 +1,4 @@
+import React from 'react';
+import s from './Logo.module.scss';
+
+export const Logo = () => LOGO
;
diff --git a/src/shared/modal-form/ModalForm.module.scss b/src/shared/modal-form/ModalForm.module.scss
new file mode 100644
index 000000000..41b30f745
--- /dev/null
+++ b/src/shared/modal-form/ModalForm.module.scss
@@ -0,0 +1,35 @@
+.modal {
+ display: flex;
+ position: fixed;
+ align-items: center;
+ z-index: 1;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 500px;
+ overflow: auto;
+ background-color: rgba(0, 0, 0, 0.4);
+
+ .modal-content {
+ background-color: #fefefe;
+ margin: 15% auto;
+ padding: 20px;
+ border: 1px solid #888;
+ width: 60%;
+
+ .close {
+ color: #aaa;
+ float: right;
+ font-size: 28px;
+ font-weight: bold;
+
+ &:hover,
+ &:focus {
+ color: black;
+ text-decoration: none;
+ cursor: pointer;
+ }
+ }
+ }
+}
+
diff --git a/src/shared/modal-form/ModalForm.tsx b/src/shared/modal-form/ModalForm.tsx
new file mode 100644
index 000000000..b8ec9c772
--- /dev/null
+++ b/src/shared/modal-form/ModalForm.tsx
@@ -0,0 +1,25 @@
+import React, { ReactNode, MouseEvent } from 'react';
+import s from './ModalForm.module.scss';
+
+type ModalFormProps = {
+ visible: boolean;
+ children: ReactNode;
+ onClose?: (event: MouseEvent) => void;
+};
+
+export const ModalForm = ({ visible = true, onClose, children }: ModalFormProps) => {
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+
+
+ ×
+
+ {children}
+
+
+ );
+};
diff --git a/src/shared/mt15/MT15.modules.scss b/src/shared/mt15/MT15.modules.scss
new file mode 100644
index 000000000..712e044d8
--- /dev/null
+++ b/src/shared/mt15/MT15.modules.scss
@@ -0,0 +1,3 @@
+.mt15 {
+ margin-top: 15px;
+}
diff --git a/src/shared/mt15/MT15.tsx b/src/shared/mt15/MT15.tsx
new file mode 100644
index 000000000..bdaf43c23
--- /dev/null
+++ b/src/shared/mt15/MT15.tsx
@@ -0,0 +1,8 @@
+import React, { ReactNode } from 'react';
+import s from './MT15.modules.scss';
+
+type MT15Props = {
+ children: ReactNode;
+};
+
+export const MT15 = ({ children }: MT15Props) => {children}
;
diff --git a/src/shared/portal-modal-form/PortalModalForm.module.scss b/src/shared/portal-modal-form/PortalModalForm.module.scss
new file mode 100644
index 000000000..d71cf65a2
--- /dev/null
+++ b/src/shared/portal-modal-form/PortalModalForm.module.scss
@@ -0,0 +1,10 @@
+.portal-modal {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+
+ input {
+ align-self: stretch;
+ }
+}
diff --git a/src/shared/portal-modal-form/PortalModalForm.tsx b/src/shared/portal-modal-form/PortalModalForm.tsx
new file mode 100644
index 000000000..2feeb0eae
--- /dev/null
+++ b/src/shared/portal-modal-form/PortalModalForm.tsx
@@ -0,0 +1,28 @@
+import React, { useState, FormEvent } from 'react';
+import { createPortal } from 'react-dom';
+import { Button } from '../button/Button';
+import { TextInput } from '../text-input/TextInput';
+import { ModalForm } from '../modal-form/ModalForm';
+import s from './PortalModalForm.module.scss';
+
+export const PortalModalForm = () => {
+ const [inputValue, setInputValue] = useState('');
+ const [isModalFormVisible, setIsModalFormVisible] = useState(false);
+
+ const openModalForm = () => setIsModalFormVisible(true);
+ const closeModalForm = () => setIsModalFormVisible(false);
+ const saveInputValue = (event: FormEvent) => setInputValue(event.currentTarget.value);
+
+ return (
+
+
+
Открыть модальное окно
+ {createPortal(
+
+ {inputValue}
+ ,
+ document.body
+ )}
+
+ );
+};
diff --git a/src/shared/providers/LangProvider.tsx b/src/shared/providers/LangProvider.tsx
new file mode 100644
index 000000000..58a7023fb
--- /dev/null
+++ b/src/shared/providers/LangProvider.tsx
@@ -0,0 +1,38 @@
+import React, { createContext, useState, useContext, ReactNode } from 'react';
+
+type Lang = 'ru' | 'en';
+type ContentKey = string;
+type ContentValue = string;
+type LangValues = Partial>;
+export type Content = Record;
+
+type LangContextType = {
+ lang: Lang;
+ useContent: (content: Content) => (key: ContentKey) => ContentValue;
+ toggleLang: () => void;
+};
+
+type LangProviderProps = {
+ children: ReactNode;
+};
+
+const LangContext = createContext(null);
+
+export const useLang = () => useContext(LangContext);
+
+const withContent =
+ (lang: Lang) =>
+ (content: Content) =>
+ (key: ContentKey): ContentValue =>
+ content[key]?.[lang];
+
+export const LangProvider = ({ children }: LangProviderProps) => {
+ const [lang, setLang] = useState('en');
+ const toggleLang = () => {
+ setLang((prevLang) => (prevLang === 'en' ? 'ru' : 'en'));
+ };
+
+ return (
+ {children}
+ );
+};
diff --git a/src/shared/providers/ThemeProvider.tsx b/src/shared/providers/ThemeProvider.tsx
new file mode 100644
index 000000000..1bdb4290e
--- /dev/null
+++ b/src/shared/providers/ThemeProvider.tsx
@@ -0,0 +1,27 @@
+import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
+
+type Theme = 'dark' | 'light';
+
+type ThemeContextType = { theme: Theme; toggleTheme: () => void };
+
+type ThemeProviderProps = {
+ children: ReactNode;
+};
+
+const ThemeContext = createContext(null);
+
+export const useTheme = () => useContext(ThemeContext);
+
+export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+ const [theme, setTheme] = useState('light');
+
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', theme);
+ }, [theme]);
+
+ const toggleTheme = () => {
+ setTheme((prevTheme) => (prevTheme === 'dark' ? 'light' : 'dark'));
+ };
+
+ return {children} ;
+};
diff --git a/src/shared/statefull-modal-form/StatefullModalForm.module.scss b/src/shared/statefull-modal-form/StatefullModalForm.module.scss
new file mode 100644
index 000000000..0247a58cc
--- /dev/null
+++ b/src/shared/statefull-modal-form/StatefullModalForm.module.scss
@@ -0,0 +1,10 @@
+.statefull-modal {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+
+ input {
+ align-self: stretch;
+ }
+}
diff --git a/src/shared/statefull-modal-form/StatefullModalForm.tsx b/src/shared/statefull-modal-form/StatefullModalForm.tsx
new file mode 100644
index 000000000..7136f5b7f
--- /dev/null
+++ b/src/shared/statefull-modal-form/StatefullModalForm.tsx
@@ -0,0 +1,24 @@
+import React, { useState, FormEvent } from 'react';
+import { Button } from '../button/Button';
+import { TextInput } from '../text-input/TextInput';
+import { ModalForm } from '../modal-form/ModalForm';
+import s from './StatefullModalForm.module.scss';
+
+export const StatefullModalForm = () => {
+ const [inputValue, setInputValue] = useState('');
+ const [isModalFormVisible, setIsModalFormVisible] = useState(false);
+
+ const openModalForm = () => setIsModalFormVisible(true);
+ const closeModalForm = () => setIsModalFormVisible(false);
+ const saveInputValue = (event: FormEvent) => setInputValue(event.currentTarget.value);
+
+ return (
+
+
+
Открыть модальное окно
+
+ {inputValue}
+
+
+ );
+};
diff --git a/src/shared/text-input/TextInput.module.scss b/src/shared/text-input/TextInput.module.scss
new file mode 100644
index 000000000..34d0b6c2d
--- /dev/null
+++ b/src/shared/text-input/TextInput.module.scss
@@ -0,0 +1,12 @@
+.text-input {
+ padding: 8px;
+ font-size: 1em;
+ border: 2px solid #ccc;
+ border-radius: 5px;
+ -webkit-transition: 0.5s;
+ transition: 0.5s;
+ outline: none;
+ &:focus {
+ border: 2px solid #555;
+ }
+}
diff --git a/src/shared/text-input/TextInput.tsx b/src/shared/text-input/TextInput.tsx
new file mode 100644
index 000000000..ce435778c
--- /dev/null
+++ b/src/shared/text-input/TextInput.tsx
@@ -0,0 +1,11 @@
+import React, { FormEvent } from 'react';
+import s from './TextInput.module.scss';
+
+type TextInputProps = {
+ value: string;
+ onInput?: (event: FormEvent) => void;
+};
+
+export const TextInput = ({ value, onInput }: TextInputProps) => (
+
+);
diff --git a/src/shared/theme-switcher/ThemeSwitcher.tsx b/src/shared/theme-switcher/ThemeSwitcher.tsx
new file mode 100644
index 000000000..87d287e59
--- /dev/null
+++ b/src/shared/theme-switcher/ThemeSwitcher.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { useTheme } from '../providers/ThemeProvider';
+import { Button } from '../button/Button';
+
+export const ThemeSwitcher = () => {
+ const { theme, toggleTheme } = useTheme();
+
+ return Switch to {theme === 'light' ? 'Dark' : 'Light'} Theme ;
+};
diff --git a/src/shared/tooltip-buttons/TooltipButtons.module.scss b/src/shared/tooltip-buttons/TooltipButtons.module.scss
new file mode 100644
index 000000000..b20682ff9
--- /dev/null
+++ b/src/shared/tooltip-buttons/TooltipButtons.module.scss
@@ -0,0 +1,5 @@
+.tooltip-buttons {
+ display: flex;
+ gap: 20px;
+ justify-content: space-between;
+}
\ No newline at end of file
diff --git a/src/shared/tooltip-buttons/TooltipButtons.tsx b/src/shared/tooltip-buttons/TooltipButtons.tsx
new file mode 100644
index 000000000..87021d8fb
--- /dev/null
+++ b/src/shared/tooltip-buttons/TooltipButtons.tsx
@@ -0,0 +1,33 @@
+import React, { ReactNode } from 'react';
+import { Position } from '../tooltip/utils/tooltipPosition';
+import { Tooltip } from '../tooltip/Tooltip';
+import { Button } from '../button/Button';
+import s from './TooltipButtons.module.scss';
+
+type PredefinedTooltipProps = {
+ children: ReactNode;
+ position: Position;
+};
+
+const PredefinedTooltip = ({ children, position }: PredefinedTooltipProps) => (
+
+ {children}
+
+);
+
+export const TooltipButtons = () => (
+
+
+ {'Слева'}
+
+
+ {'Справа'}
+
+
+ {'Сверху'}
+
+
+ {'Снизу'}
+
+
+);
diff --git a/src/shared/tooltip/Tooltip.module.scss b/src/shared/tooltip/Tooltip.module.scss
new file mode 100644
index 000000000..ece4fb9dd
--- /dev/null
+++ b/src/shared/tooltip/Tooltip.module.scss
@@ -0,0 +1,21 @@
+.tooltip-target {
+ display: inline-block;
+ position: relative;
+}
+
+.tooltip-content {
+ z-index: 1;
+ position: absolute;
+ padding: 5px;
+ border-radius: 5px;
+ background-color: black;
+ color: whitesmoke;
+ opacity: 1;
+ transition-duration: var(--tooltip-animation-ms, 1000ms);
+ transition-property: opacity;
+ transition-timing-function: ease;
+}
+
+.fade-out {
+ opacity: 0;
+}
diff --git a/src/shared/tooltip/Tooltip.tsx b/src/shared/tooltip/Tooltip.tsx
new file mode 100644
index 000000000..9b9164918
--- /dev/null
+++ b/src/shared/tooltip/Tooltip.tsx
@@ -0,0 +1,40 @@
+import React, { ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { useTooltip } from './hooks/useTooltip';
+import { Position } from './utils/tooltipPosition';
+import cn from 'clsx';
+import s from './Tooltip.module.scss';
+
+type TooltipProps = {
+ children: ReactNode;
+ content: ReactNode;
+ duration?: number;
+ position?: Position;
+};
+
+export const Tooltip = ({ children, content, duration = 1000, position = 'bottom' }: TooltipProps) => {
+ const { visible, mounted, coords, tooltipRef, targetRef, handleMouseEnter, handleMouseLeave } = useTooltip(
+ position,
+ duration
+ );
+
+ return (
+ <>
+
+ {children}
+
+ {mounted &&
+ createPortal(
+
+ {content}
+
,
+ document.body
+ )}
+ >
+ );
+};
diff --git a/src/shared/tooltip/hooks/useTooltip.ts b/src/shared/tooltip/hooks/useTooltip.ts
new file mode 100644
index 000000000..f814037a7
--- /dev/null
+++ b/src/shared/tooltip/hooks/useTooltip.ts
@@ -0,0 +1,59 @@
+import { useState, useRef, useLayoutEffect } from 'react';
+import { positionMap, Position } from '../utils/tooltipPosition';
+
+export const useTooltip = (position: Position = 'bottom', duration = 1000) => {
+ const [visible, setVisible] = useState(false);
+ const [mounted, setMounted] = useState(false);
+ const [coords, setCoords] = useState({ top: 0, left: 0 });
+ const tooltipRef = useRef(null);
+ const targetRef = useRef(null);
+ const timerRef = useRef(null);
+ const mountTimerRef = useRef(null);
+
+ const mountTimer = 50;
+
+ const clearTimeouts = () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ if (mountTimerRef.current) clearTimeout(mountTimerRef.current);
+ };
+
+ const handleMouseEnter = () => {
+ clearTimeouts();
+ setMounted(true);
+ mountTimerRef.current = window.setTimeout(() => setVisible(true), mountTimer);
+ };
+
+ const handleMouseLeave = () => {
+ setVisible(false);
+ timerRef.current = window.setTimeout(() => setMounted(false), duration + mountTimer);
+ };
+
+ useLayoutEffect(() => {
+ const target = targetRef.current;
+ const tooltip = tooltipRef.current;
+
+ if (!target || !tooltip) return;
+
+ if (mounted) {
+ tooltipRef.current?.style.setProperty('--tooltip-animation-ms', `${duration + mountTimer}ms`);
+ const targetRect = target.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+ const calcPosition = positionMap[position] ?? positionMap['bottom'];
+ setCoords(calcPosition({ targetRect, tooltipRect, offset: 5 }));
+ }
+
+ return () => {
+ clearTimeouts();
+ };
+ }, [mounted, position]);
+
+ return {
+ visible,
+ mounted,
+ coords,
+ tooltipRef,
+ targetRef,
+ handleMouseEnter,
+ handleMouseLeave,
+ };
+};
diff --git a/src/shared/tooltip/utils/tooltipPosition.ts b/src/shared/tooltip/utils/tooltipPosition.ts
new file mode 100644
index 000000000..870e76073
--- /dev/null
+++ b/src/shared/tooltip/utils/tooltipPosition.ts
@@ -0,0 +1,42 @@
+type Coords = {
+ top: number;
+ left: number;
+};
+
+type CoordProps = {
+ targetRect: DOMRect;
+ tooltipRect: DOMRect;
+ offset: number;
+};
+
+export type Position = 'top' | 'bottom' | 'left' | 'right';
+
+const getCenterCoord = (primary: number, secondary: number) => (primary - secondary) / 2;
+
+const YLeft = (primary: DOMRect, secondary: DOMRect) =>
+ primary.left + window.scrollX + getCenterCoord(primary.width, secondary.width);
+
+const XTop = (primary: DOMRect, secondary: DOMRect) =>
+ primary.top + window.scrollY + getCenterCoord(primary.height, secondary.height);
+
+export const positionMap: Record Coords> = {
+ top: ({ targetRect, tooltipRect, offset }) => ({
+ top: targetRect.top + window.scrollY - tooltipRect.height - offset,
+ left: targetRect.left + window.scrollX + getCenterCoord(targetRect.width, tooltipRect.width),
+ }),
+
+ bottom: ({ targetRect, tooltipRect, offset }) => ({
+ top: targetRect.bottom + window.scrollY + offset,
+ left: YLeft(targetRect, tooltipRect),
+ }),
+
+ left: ({ targetRect, tooltipRect, offset }) => ({
+ top: XTop(targetRect, tooltipRect),
+ left: targetRect.left + window.scrollX - tooltipRect.width - offset,
+ }),
+
+ right: ({ targetRect, tooltipRect, offset }) => ({
+ top: XTop(targetRect, tooltipRect),
+ left: targetRect.left + window.scrollX + targetRect.width + offset,
+ }),
+};
diff --git a/src/stories/AddToCart.stories.ts b/src/stories/AddToCart.stories.ts
new file mode 100644
index 000000000..17c30a2db
--- /dev/null
+++ b/src/stories/AddToCart.stories.ts
@@ -0,0 +1,17 @@
+import type { Meta } from '@storybook/react';
+
+import { AddToCart } from '../shared/i-shop/add-to-cart/AddToCart';
+
+const meta: Meta = {
+ component: AddToCart,
+ title: 'Интернет-магазин/В корзину',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ quantity: 5,
+ },
+};
diff --git a/src/stories/Button.stories.ts b/src/stories/Button.stories.ts
deleted file mode 100644
index 8e2958cdd..000000000
--- a/src/stories/Button.stories.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { Meta } from '@storybook/react';
-
-import { Button } from './Button';
-
-// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
-const meta: Meta = {
- title: 'Example/Button',
- component: Button,
- tags: ['autodocs'],
- argTypes: {
- backgroundColor: { control: 'color' },
- },
-};
-
-export default meta;
-
-// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
-export const Primary = {
- args: {
- primary: true,
- label: 'Button',
- },
-};
-
-export const Secondary = {
- args: {
- label: 'Button',
- },
-};
-
-export const Large = {
- args: {
- size: 'large',
- label: 'Button',
- },
-};
-
-export const Small = {
- args: {
- size: 'small',
- label: 'Button',
- },
-};
diff --git a/src/stories/Button.tsx b/src/stories/Button.tsx
deleted file mode 100644
index 244ff033e..000000000
--- a/src/stories/Button.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import s from './button.module.sass';
-
-interface ButtonProps {
- /**
- * Is this the principal call to action on the page?
- */
- primary?: boolean;
- /**
- * What background color to use
- */
- backgroundColor?: string;
- /**
- * How large should the button be?
- */
- size?: 'small' | 'medium' | 'large';
- /**
- * Button contents
- */
- label: string;
- /**
- * Optional click handler
- */
- onClick?: () => void;
-}
-
-/**
- * Primary UI component for user interaction
- */
-export function Button({ primary = false, size = 'medium', backgroundColor, label, ...props }: ButtonProps) {
- const mode = primary ? s.primary : s.secondary;
- return (
-
- {label}
-
- );
-}
diff --git a/src/stories/Collapse.stories.tsx b/src/stories/Collapse.stories.tsx
new file mode 100644
index 000000000..e43b503a4
--- /dev/null
+++ b/src/stories/Collapse.stories.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import type { Meta } from '@storybook/react';
+
+import { Collapse } from '../shared/collapse/Collapse';
+import { Button } from '../shared/button/Button';
+
+const meta: Meta = {
+ component: Collapse,
+ title: 'Сложные компоненты/Сворачивание',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ title: 'Сворачиваемый контент',
+ children: (
+ <>
+ Some button
+
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Incidunt, perferendis! Non autem consectetur amet
+ sint at saepe veniam doloremque, culpa dolore corporis ad consequuntur temporibus? Magni laudantium eum dolor
+ tempora.
+
+ >
+ ),
+ },
+};
diff --git a/src/stories/Header.stories.ts b/src/stories/Header.stories.ts
deleted file mode 100644
index c74c3732a..000000000
--- a/src/stories/Header.stories.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-import { Header } from './Header';
-
-const meta: Meta = {
- title: 'Example/Header',
- component: Header,
- // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
- tags: ['autodocs'],
- parameters: {
- // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
- layout: 'fullscreen',
- },
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const LoggedIn = {
- args: {
- user: {
- name: 'Jane Doe',
- },
- },
-};
-
-export const LoggedOut: Story = {};
diff --git a/src/stories/Header.stories.tsx b/src/stories/Header.stories.tsx
new file mode 100644
index 000000000..8f56a2b0a
--- /dev/null
+++ b/src/stories/Header.stories.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import type { Meta } from '@storybook/react';
+
+import { Header, HeaderProps } from '../shared/header/Header';
+import { ThemeProvider } from '../shared/providers/ThemeProvider';
+import { LangProvider } from '../shared/providers/LangProvider';
+import './theme.css';
+
+const HeaderWithThemeAndLangProviders = (data: HeaderProps) => (
+
+
+
+
+
+);
+
+const meta: Meta = {
+ component: HeaderWithThemeAndLangProviders,
+ title: 'Общее задание/Header',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const WithLogo = {
+ args: {
+ showLogo: true,
+ },
+};
diff --git a/src/stories/Header.tsx b/src/stories/Header.tsx
deleted file mode 100644
index 555e7ce46..000000000
--- a/src/stories/Header.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-
-import { Button } from './Button';
-import './header.css';
-
-type User = {
- name: string;
-};
-
-interface HeaderProps {
- user?: User;
- onLogin: () => void;
- onLogout: () => void;
- onCreateAccount: () => void;
-}
-
-export function Header({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) {
- return (
-
- );
-}
diff --git a/src/stories/InputToModal.stories.ts b/src/stories/InputToModal.stories.ts
new file mode 100644
index 000000000..b44835707
--- /dev/null
+++ b/src/stories/InputToModal.stories.ts
@@ -0,0 +1,20 @@
+import type { Meta } from '@storybook/react';
+
+import { StatefullModalForm } from '../shared/statefull-modal-form/StatefullModalForm';
+
+const meta: Meta = {
+ component: StatefullModalForm,
+ title: 'Общее задание/Модальное окно/С состоянием',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ story: {
+ height: '500px',
+ },
+ },
+ },
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/InputToPortalModal.stories.ts b/src/stories/InputToPortalModal.stories.ts
new file mode 100644
index 000000000..acc9f8b36
--- /dev/null
+++ b/src/stories/InputToPortalModal.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { PortalModalForm } from '../shared/portal-modal-form/PortalModalForm';
+
+const meta: Meta = {
+ component: PortalModalForm,
+ title: 'Общее задание/Модальное окно/С порталом',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/Layout.stories.tsx b/src/stories/Layout.stories.tsx
new file mode 100644
index 000000000..69baadb27
--- /dev/null
+++ b/src/stories/Layout.stories.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import type { Meta } from '@storybook/react';
+
+import { Layout } from '../shared/layout/Layout';
+import { ThemeProvider } from '../shared/providers/ThemeProvider';
+import { LangProvider } from '../shared/providers/LangProvider';
+import './theme.css';
+
+const LayoutWithThemeAndLangProviders = () => (
+
+
+
+
+
+);
+
+const meta: Meta = {
+ component: LayoutWithThemeAndLangProviders,
+ title: 'Общее задание/Layout',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const LayoutWithContent = {};
diff --git a/src/stories/Logo.stories.ts b/src/stories/Logo.stories.ts
new file mode 100644
index 000000000..41f52e347
--- /dev/null
+++ b/src/stories/Logo.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { Logo } from '../shared/logo/Logo';
+
+const meta: Meta = {
+ component: Logo,
+ title: 'Общее задание/Logo',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const CSSLogo = {};
diff --git a/src/stories/ModalForm.stories.tsx b/src/stories/ModalForm.stories.tsx
new file mode 100644
index 000000000..899486b85
--- /dev/null
+++ b/src/stories/ModalForm.stories.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import type { Meta } from '@storybook/react';
+
+import { ModalForm } from '../shared/modal-form/ModalForm';
+
+const SomeContent = () => (
+
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Et voluptatum laudantium officiis, maiores cum sunt dolores
+ maxime deserunt distinctio optio ipsa illum veniam consectetur corrupti laboriosam debitis corporis voluptates
+ cumque.
+
+);
+
+const meta: Meta = {
+ component: ModalForm,
+ title: 'Общее задание/Модальное окно/Модальное окно',
+ tags: ['autodocs'],
+ argTypes: {
+ children: {
+ control: { type: 'boolean' },
+ mapping: { false: '', true: },
+ },
+ },
+ parameters: {
+ docs: {
+ story: {
+ height: '500px',
+ },
+ },
+ },
+};
+
+export default meta;
+
+export const NotVisible = {
+ args: {
+ visible: false,
+ },
+};
+
+export const Blank = {
+ args: {
+ visible: true,
+ },
+};
+
+export const WithContent = {
+ args: {
+ visible: true,
+ children: ,
+ },
+};
diff --git a/src/stories/OpearationDynamicList.stories.tsx b/src/stories/OpearationDynamicList.stories.tsx
new file mode 100644
index 000000000..9bdf4e500
--- /dev/null
+++ b/src/stories/OpearationDynamicList.stories.tsx
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { OperationDynamicList } from '../shared/icome-expenses-accounting/operation-dynamic-list/OperationDynamicList';
+
+const meta: Meta = {
+ component: OperationDynamicList,
+ title: 'Учет доходов-расходов/Список операций/Динамический с автодобавлением',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/OperationDetail.stories.tsx b/src/stories/OperationDetail.stories.tsx
new file mode 100644
index 000000000..7d1fc2782
--- /dev/null
+++ b/src/stories/OperationDetail.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta } from '@storybook/react';
+
+import { OperationDetail } from '../shared/icome-expenses-accounting/operation-detail/OperationDetail';
+
+const meta: Meta = {
+ component: OperationDetail,
+ title: 'Учет доходов-расходов/Операция/Полная',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ amount: 2999.99,
+ category: 'оплата',
+ title: 'Подписка',
+ description:
+ 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus omnis tempore cupiditate magni ad porro nihil consectetur a voluptas, rerum error, maiores rem, ut adipisci sint? Esse excepturi at non?',
+ date: new Date().toLocaleDateString(),
+ },
+};
diff --git a/src/stories/OperationForm.stories.ts b/src/stories/OperationForm.stories.ts
new file mode 100644
index 000000000..87b7f0317
--- /dev/null
+++ b/src/stories/OperationForm.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { OperationForm } from '../pages/OperationForm/OperationForm';
+
+const meta: Meta = {
+ component: OperationForm,
+ title: 'Формы/Операция',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/OperationList.stories.tsx b/src/stories/OperationList.stories.tsx
new file mode 100644
index 000000000..9599615c3
--- /dev/null
+++ b/src/stories/OperationList.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta } from '@storybook/react';
+
+import { OperationList } from '../shared/icome-expenses-accounting/operation-list/OperationList';
+import { createRandomOperation } from '../homeworks/ts1/3_write';
+
+const meta: Meta = {
+ component: OperationList,
+ title: 'Учет доходов-расходов/Список операций/Простой',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ items: [
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ createRandomOperation(new Date().toLocaleDateString()),
+ ],
+ },
+};
diff --git a/src/stories/OperationListAddMore.stories.tsx b/src/stories/OperationListAddMore.stories.tsx
new file mode 100644
index 000000000..a7f41d485
--- /dev/null
+++ b/src/stories/OperationListAddMore.stories.tsx
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { OperationListAddMore } from '../shared/icome-expenses-accounting/operation-list-add-more/OperationListAddMore';
+
+const meta: Meta = {
+ component: OperationListAddMore,
+ title: 'Учет доходов-расходов/Список операций/Динамический с кнопкой',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/OperationSummary.stories.tsx b/src/stories/OperationSummary.stories.tsx
new file mode 100644
index 000000000..33aadc38c
--- /dev/null
+++ b/src/stories/OperationSummary.stories.tsx
@@ -0,0 +1,21 @@
+import type { Meta } from '@storybook/react';
+
+import { OperationSummary } from '../shared/icome-expenses-accounting/operation-summary/OperationSummary';
+
+const meta: Meta = {
+ component: OperationSummary,
+ title: 'Учет доходов-расходов/Операция/Краткая',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ amount: 2999.99,
+ category: 'оплата',
+ title: 'Подписка',
+ shortDescription:
+ 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus omnis tempore cupiditate magni ad porro nihil consectetur a voluptas, rerum error, maiores rem, ut adipisci sint? Esse excepturi at non?',
+ },
+};
diff --git a/src/stories/Page.stories.ts b/src/stories/Page.stories.ts
deleted file mode 100644
index 905187f96..000000000
--- a/src/stories/Page.stories.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-import { within, userEvent } from '@storybook/testing-library';
-
-import { Page } from './Page';
-
-const meta: Meta = {
- title: 'Example/Page',
- component: Page,
- parameters: {
- // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
- layout: 'fullscreen',
- },
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const LoggedOut: Story = {};
-
-// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing
-export const LoggedIn: Story = {
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const loginButton = await canvas.getByRole('button', {
- name: /Log in/i,
- });
- await userEvent.click(loginButton);
- },
-};
diff --git a/src/stories/Page.tsx b/src/stories/Page.tsx
deleted file mode 100644
index 7b01ba5ce..000000000
--- a/src/stories/Page.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import { Header } from './Header';
-import './page.css';
-
-type User = {
- name: string;
-};
-
-export const Page: React.FC = () => {
- const [user, setUser] = React.useState();
-
- return (
-
- setUser({ name: 'Jane Doe' })}
- onLogout={() => setUser(undefined)}
- onCreateAccount={() => setUser({ name: 'Jane Doe' })}
- />
-
-
- Pages in Storybook
-
- We recommend building UIs with a{' '}
-
- component-driven
- {' '}
- process starting with atomic components and ending with pages.
-
-
- Render pages with mock data. This makes it easy to build and review page states without needing to navigate to
- them in your app. Here are some handy patterns for managing page data in Storybook:
-
-
-
- Use a higher-level connected component. Storybook helps you compose such data from the args of child
- component stories
-
-
- Assemble data in the page component from your services. You can mock these services out using Storybook.
-
-
-
- Get a guided tutorial on component-driven development at{' '}
-
- Storybook tutorials
-
- . Read more in the{' '}
-
- docs
-
- .
-
-
-
Tip Adjust the width of the canvas with the{' '}
-
-
-
-
-
- Viewports addon in the toolbar
-
-
-
- );
-};
diff --git a/src/stories/ProductCart.stories.ts b/src/stories/ProductCart.stories.ts
new file mode 100644
index 000000000..45db0aa4e
--- /dev/null
+++ b/src/stories/ProductCart.stories.ts
@@ -0,0 +1,20 @@
+import type { Meta } from '@storybook/react';
+
+import { ProductCart } from '../shared/i-shop/product-cart/ProductCart';
+
+const meta: Meta = {
+ component: ProductCart,
+ title: 'Интернет-магазин/Товар для корзины',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ product: {
+ title: 'TEST Title',
+ price: 999.99,
+ },
+ },
+};
diff --git a/src/stories/ProductDetail.stories.ts b/src/stories/ProductDetail.stories.ts
new file mode 100644
index 000000000..64c5427b6
--- /dev/null
+++ b/src/stories/ProductDetail.stories.ts
@@ -0,0 +1,22 @@
+import type { Meta } from '@storybook/react';
+
+import { ProductDetail } from '../shared/i-shop/product-detail/ProductDetail';
+
+const meta: Meta = {
+ component: ProductDetail,
+ title: 'Интернет-магазин/Полное отображение',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ title: 'TEST Title',
+ price: 999.99,
+ category: 'овощи',
+ images: ['https://placehold.co/600x400', 'https://placehold.co/600x400'],
+ description:
+ 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus omnis tempore cupiditate magni ad porro nihil consectetur a voluptas, rerum error, maiores rem, ut adipisci sint? Esse excepturi at non?',
+ },
+};
diff --git a/src/stories/ProductForm.stories.ts b/src/stories/ProductForm.stories.ts
new file mode 100644
index 000000000..bdcef7588
--- /dev/null
+++ b/src/stories/ProductForm.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { ProductForm } from '../pages/ProductForm/ProductForm';
+
+const meta: Meta = {
+ component: ProductForm,
+ title: 'Формы/Товар',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/ProductSummary.stories.ts b/src/stories/ProductSummary.stories.ts
new file mode 100644
index 000000000..a291cf844
--- /dev/null
+++ b/src/stories/ProductSummary.stories.ts
@@ -0,0 +1,21 @@
+import type { Meta } from '@storybook/react';
+
+import { ProductSummary } from '../shared/i-shop/product-summary/ProductSummary';
+
+const meta: Meta = {
+ component: ProductSummary,
+ title: 'Интернет-магазин/Краткое отображение',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ title: 'TEST Title',
+ price: 999.99,
+ image: 'https://placehold.co/600x400',
+ shortDescription:
+ 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus omnis tempore cupiditate magni ad porro nihil consectetur a voluptas, rerum error, maiores rem, ut adipisci sint? Esse excepturi at non?',
+ },
+};
diff --git a/src/stories/ProfileForm.stories.ts b/src/stories/ProfileForm.stories.ts
new file mode 100644
index 000000000..98aa6cc44
--- /dev/null
+++ b/src/stories/ProfileForm.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { ProfileForm } from '../pages/ProfileForm/ProfileForm';
+
+const meta: Meta = {
+ component: ProfileForm,
+ title: 'Формы/Профиль',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/SignInForm.stories.ts b/src/stories/SignInForm.stories.ts
new file mode 100644
index 000000000..57d9d6528
--- /dev/null
+++ b/src/stories/SignInForm.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { SignInForm } from '../pages/SignInForm/SignInForm';
+
+const meta: Meta = {
+ component: SignInForm,
+ title: 'Формы/Вход',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/SignUpForm.stories.ts b/src/stories/SignUpForm.stories.ts
new file mode 100644
index 000000000..bb462cbec
--- /dev/null
+++ b/src/stories/SignUpForm.stories.ts
@@ -0,0 +1,13 @@
+import type { Meta } from '@storybook/react';
+
+import { SignUpForm } from '../pages/SignUpForm/SignUpForm';
+
+const meta: Meta = {
+ component: SignUpForm,
+ title: 'Формы/Регистрация',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/TooltipInlineText.stories.tsx b/src/stories/TooltipInlineText.stories.tsx
new file mode 100644
index 000000000..fc88a0f38
--- /dev/null
+++ b/src/stories/TooltipInlineText.stories.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import type { Meta } from '@storybook/react';
+
+import { Tooltip } from '../shared/tooltip/Tooltip';
+
+type TooltipInlineTextProps = {
+ content: React.ReactNode;
+};
+
+const TooltipInlineText = ({ content }: TooltipInlineTextProps) => (
+
+ {'Lorem ipsum dolor sit amet '}
+
+ consectetur
+
+ {' adipisicing elit.'}
+
+);
+
+const meta: Meta = {
+ component: TooltipInlineText,
+ title: 'Сложные компоненты/Подсказка/В тексте',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ content: 'Плавно всплывающая подсказка',
+ },
+};
diff --git a/src/stories/TooltipOnButtons.stories.ts b/src/stories/TooltipOnButtons.stories.ts
new file mode 100644
index 000000000..fa36d0e9e
--- /dev/null
+++ b/src/stories/TooltipOnButtons.stories.ts
@@ -0,0 +1,12 @@
+import type { Meta } from '@storybook/react';
+import { TooltipButtons } from '../shared/tooltip-buttons/TooltipButtons';
+
+const meta: Meta = {
+ component: TooltipButtons,
+ title: 'Сложные компоненты/Подсказка/Позиционирование',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {};
diff --git a/src/stories/TooltipWithProperties.stories.tsx b/src/stories/TooltipWithProperties.stories.tsx
new file mode 100644
index 000000000..1a628cc08
--- /dev/null
+++ b/src/stories/TooltipWithProperties.stories.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import type { Meta } from '@storybook/react';
+
+import { Tooltip } from '../shared/tooltip/Tooltip';
+import { Button } from '../shared/button/Button';
+
+const meta: Meta = {
+ component: Tooltip,
+ title: 'Сложные компоненты/Подсказка/С настройками',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+export const Test = {
+ args: {
+ content: 'Подсказка',
+ duration: 1000,
+ position: 'top',
+ children: Наведи на меня ,
+ },
+};
diff --git a/src/stories/button.module.sass b/src/stories/button.module.sass
deleted file mode 100644
index afb77db88..000000000
--- a/src/stories/button.module.sass
+++ /dev/null
@@ -1,30 +0,0 @@
-.button
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif
- font-weight: 700
- border: 0
- border-radius: 3em
- cursor: pointer
- display: inline-block
- line-height: 1
-
- &.primary
- color: white
- background-color: #1ea7fd
-
- &.secondary
- color: #333
- background-color: transparent
- box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset
-
- &.small
- font-size: 12px
- padding: 10px 16px
-
- &.medium
- font-size: 14px
- padding: 11px 20px
-
- &.large
- font-size: 16px
- padding: 12px 24px
-
diff --git a/src/stories/header.css b/src/stories/header.css
deleted file mode 100644
index e30766072..000000000
--- a/src/stories/header.css
+++ /dev/null
@@ -1,32 +0,0 @@
-.storybook-header {
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
- padding: 15px 20px;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.storybook-header svg {
- display: inline-block;
- vertical-align: top;
-}
-
-.storybook-header h1 {
- font-weight: 700;
- font-size: 20px;
- line-height: 1;
- margin: 6px 0 6px 10px;
- display: inline-block;
- vertical-align: top;
-}
-
-.storybook-header button + button {
- margin-left: 10px;
-}
-
-.storybook-header .welcome {
- color: #333;
- font-size: 14px;
- margin-right: 10px;
-}
diff --git a/src/stories/page.css b/src/stories/page.css
deleted file mode 100644
index 139f6885a..000000000
--- a/src/stories/page.css
+++ /dev/null
@@ -1,69 +0,0 @@
-.storybook-page {
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
- font-size: 14px;
- line-height: 24px;
- padding: 48px 20px;
- margin: 0 auto;
- max-width: 600px;
- color: #333;
-}
-
-.storybook-page h2 {
- font-weight: 700;
- font-size: 32px;
- line-height: 1;
- margin: 0 0 4px;
- display: inline-block;
- vertical-align: top;
-}
-
-.storybook-page p {
- margin: 1em 0;
-}
-
-.storybook-page a {
- text-decoration: none;
- color: #1ea7fd;
-}
-
-.storybook-page ul {
- padding-left: 30px;
- margin: 1em 0;
-}
-
-.storybook-page li {
- margin-bottom: 8px;
-}
-
-.storybook-page .tip {
- display: inline-block;
- border-radius: 1em;
- font-size: 11px;
- line-height: 12px;
- font-weight: 700;
- background: #e7fdd8;
- color: #66bf3c;
- padding: 4px 12px;
- margin-right: 10px;
- vertical-align: top;
-}
-
-.storybook-page .tip-wrapper {
- font-size: 13px;
- line-height: 20px;
- margin-top: 40px;
- margin-bottom: 40px;
-}
-
-.storybook-page .tip-wrapper svg {
- display: inline-block;
- height: 12px;
- width: 12px;
- margin-right: 4px;
- vertical-align: top;
- margin-top: 3px;
-}
-
-.storybook-page .tip-wrapper svg path {
- fill: #1ea7fd;
-}
diff --git a/src/stories/theme.css b/src/stories/theme.css
new file mode 100644
index 000000000..a5531e7c6
--- /dev/null
+++ b/src/stories/theme.css
@@ -0,0 +1,17 @@
+[data-theme='light'] {
+ --background-color: #fff;
+ --color: #000;
+ --logo-bg-color: #333 ;
+ --logo-text-color: #f4f4f4;
+ --header-bg-color: #dbdbdb;
+ --layout-bg-color: #fafafa;
+}
+
+[data-theme='dark'] {
+ --background-color: #333;
+ --color: #fff;
+ --logo-bg-color: #f4f4f4;
+ --logo-text-color: #333;
+ --header-bg-color: #333;
+ --layout-bg-color: #4e4e4e;
+}