From e61e9501545c8a7ab6e16d9a5167f17c8d1db222 Mon Sep 17 00:00:00 2001 From: Ivan Komrakov Date: Tue, 11 Mar 2025 23:07:42 +0300 Subject: [PATCH 1/9] feat: add personal information to App.tsx feat: modify style App.css feat: modify package.json feat: modify webpack.config.json --- package-lock.json | 159 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +- src/app/App.css | 7 ++ src/app/App.tsx | 32 +++++++++- webpack.config.js | 2 +- 5 files changed, 201 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b1dfa392..f8dd96c7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.8.0", "fork-ts-checker-webpack-plugin": "^8.0.0", + "gh-pages": "^6.3.0", "html-webpack-plugin": "^5.5.1", "husky": "^8.0.0", "jest": "^29.5.0", @@ -11021,6 +11022,13 @@ "integrity": "sha512-8KR114CAYQ4/r5EIEsOmOMqQ9j0MRbJZR3aXD/KFA8RuKzyoUB4XrUCg+l8RUGqTVQgKNIgTpjaG8YHRPAbX2w==", "dev": true }, + "node_modules/email-addresses": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz", + "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", + "dev": true, + "license": "MIT" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -12511,6 +12519,34 @@ "node": ">=10" } }, + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -13147,6 +13183,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gh-pages": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", + "integrity": "sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "commander": "^13.0.0", + "email-addresses": "^5.0.0", + "filenamify": "^4.3.0", + "find-cache-dir": "^3.3.1", + "fs-extra": "^11.1.1", + "globby": "^11.1.0" + }, + "bin": { + "gh-pages": "bin/gh-pages.js", + "gh-pages-clean": "bin/gh-pages-clean.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gh-pages/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/giget": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.1.tgz", @@ -21335,6 +21404,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-loader": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", @@ -21964,6 +22046,19 @@ "node": ">=12" } }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -31513,6 +31608,12 @@ "integrity": "sha512-8KR114CAYQ4/r5EIEsOmOMqQ9j0MRbJZR3aXD/KFA8RuKzyoUB4XrUCg+l8RUGqTVQgKNIgTpjaG8YHRPAbX2w==", "dev": true }, + "email-addresses": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz", + "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", + "dev": true + }, "emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -32645,6 +32746,23 @@ } } }, + "filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "dev": true + }, + "filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "dev": true, + "requires": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -33105,6 +33223,29 @@ "get-intrinsic": "^1.1.1" } }, + "gh-pages": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", + "integrity": "sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==", + "dev": true, + "requires": { + "async": "^3.2.4", + "commander": "^13.0.0", + "email-addresses": "^5.0.0", + "filenamify": "^4.3.0", + "find-cache-dir": "^3.3.1", + "fs-extra": "^11.1.1", + "globby": "^11.1.0" + }, + "dependencies": { + "commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true + } + } + }, "giget": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.1.tgz", @@ -39034,6 +39175,15 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, "style-loader": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", @@ -39512,6 +39662,15 @@ "punycode": "^2.1.1" } }, + "trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, "trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", diff --git a/package.json b/package.json index 492664d1f..fadf9ed05 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,13 @@ "version": "1.0.0", "description": "Start repo with required configuration", "main": "src/index.tsx", - "author": "Igor ", + "author": "Ivan ", + "homepage": "https://ivandetch.github.io", "scripts": { "start": "webpack serve --mode development", "build": "webpack --mode production", + "predeploy": "npm run build", + "deploy": "npx gh-pages -d dist", "test": "jest src", "lint": "eslint src --fix", "storybook": "storybook dev -p 6006", @@ -46,6 +49,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.8.0", "fork-ts-checker-webpack-plugin": "^8.0.0", + "gh-pages": "^6.3.0", "html-webpack-plugin": "^5.5.1", "husky": "^8.0.0", "jest": "^29.5.0", diff --git a/src/app/App.css b/src/app/App.css index 78b8850cf..d0d0b02eb 100644 --- a/src/app/App.css +++ b/src/app/App.css @@ -28,6 +28,13 @@ color: #61dafb; } +.App-body { + margin: 0 5%; +} +.App-body p { + text-align: justify; +} + @keyframes App-logo-spin { from { transform: rotate(0deg); diff --git a/src/app/App.tsx b/src/app/App.tsx index dcc0ff8ad..257db87f5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,9 +7,35 @@ function App() {
logo -

- Текст писать тут -

+
+

Иван Комраков

+

+ Цель: + Хочу приобрести/структурировать новые знания и навыки о React, чтобы применить их на работе. +

+

+ Технологии: +

    +
  • JavaScript (ES6), jQuery, HTML, CSS3 (препроцессоры SCSS/SASS), Bootstrap, PHP 7.3+, Laravel 8+ .
  • +
  • Частичная работа с React 18, Node.js.
  • +
  • Rest API.
  • +
  • Сборщики: Laravel mix. React vite/WebPack .
  • +
  • Composer, NPM, Kubernets, Docker.
  • +
  • SQL(MySQL,MariaDB), MongoDB.
  • +
  • Git, GitLab, GitLab CI/CD.
  • +
  • CMS WordPress.
  • +
  • Figma, Jira.
  • +
+

+

+ Опыт: + Имею опыт работы в области веб-разработки (профессиональный опыт 7 лет), обладаю знаниями и навыками верстки, Frontend и Backend-технологий. +

+

+ Контакты: + kmrakvi@gmail.com +

+
); diff --git a/webpack.config.js b/webpack.config.js index 583dfd1ed..067e8a0fb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -32,7 +32,7 @@ module.exports = (_, args) => { output: { path: dist, publicPath: - args.mode === 'development' ? `http://${host}:${port}/` : undefined /* <- прописать данные своего github */, + args.mode === 'development' ? `http://${host}:${port}/` : './' /* <- прописать данные своего github */, filename: `js/[name].js`, chunkFilename: `js/[name].js`, }, From 9b5fbb8ee0eff084c9126392adea070fe1b88773 Mon Sep 17 00:00:00 2001 From: Ivan Komrakov Date: Fri, 8 Aug 2025 04:19:32 +0300 Subject: [PATCH 2/9] Feat: Created components header, layout, logo, modal, transactionBrief, transactionFull, addToCartButton, cartItem, productBrief, productFull. --- .github/workflows/main.yml | 38 ++-- .storybook/main.ts | 2 +- .../AddToCartButton.module.css | 92 +++++++++ .../AddToCartButton.stories.tsx | 43 +++++ .../AddToCartButton/AddToCartButton.tsx | 42 +++++ .../ecommerce/AddToCartButton/index.ts | 1 + .../ecommerce/CartItem/CartItem.module.css | 175 ++++++++++++++++++ .../ecommerce/CartItem/CartItem.stories.tsx | 45 +++++ .../ecommerce/CartItem/CartItem.tsx | 45 +++++ src/components/ecommerce/CartItem/index.ts | 1 + .../ProductBrief/ProductBrief.module.css | 117 ++++++++++++ .../ProductBrief/ProductBrief.stories.tsx | 43 +++++ .../ecommerce/ProductBrief/ProductBrief.tsx | 34 ++++ .../ecommerce/ProductBrief/index.ts | 1 + .../ProductFull/ProductFull.module.css | 162 ++++++++++++++++ .../ProductFull/ProductFull.stories.tsx | 36 ++++ .../ecommerce/ProductFull/ProductFull.tsx | 38 ++++ src/components/ecommerce/ProductFull/index.ts | 1 + src/components/ecommerce/index.ts | 4 + .../TransactionBrief.module.css | 91 +++++++++ .../TransactionBrief.stories.tsx | 56 ++++++ .../TransactionBrief/TransactionBrief.tsx | 34 ++++ .../Transaction/TransactionBrief/index.ts | 1 + .../TransactionFull.module.css | 166 +++++++++++++++++ .../TransactionFull.stories.tsx | 47 +++++ .../TransactionFull/TransactionFull.tsx | 58 ++++++ .../Transaction/TransactionFull/index.ts | 1 + src/components/finance/Transaction/index.ts | 3 + src/components/finance/index.ts | 2 + src/components/index.ts | 21 +++ .../layout/Header/Header.module.css | 29 +++ .../layout/Header/Header.stories.tsx | 52 ++++++ src/components/layout/Header/Header.tsx | 20 ++ src/components/layout/Header/index.ts | 1 + .../layout/Layout/Layout.module.css | 28 +++ .../layout/Layout/Layout.stories.tsx | 65 +++++++ src/components/layout/Layout/Layout.tsx | 17 ++ src/components/layout/Layout/index.ts | 1 + src/components/layout/index.ts | 2 + src/components/types.ts | 79 ++++++++ src/components/ui/Logo/Logo.module.css | 52 ++++++ src/components/ui/Logo/Logo.stories.tsx | 44 +++++ src/components/ui/Logo/Logo.tsx | 14 ++ src/components/ui/Logo/index.ts | 1 + src/components/ui/Modal/Modal.module.css | 55 ++++++ src/components/ui/Modal/Modal.stories.tsx | 47 +++++ src/components/ui/Modal/Modal.tsx | 24 +++ src/components/ui/Modal/index.ts | 1 + src/components/ui/index.ts | 2 + 49 files changed, 1914 insertions(+), 20 deletions(-) create mode 100644 src/components/ecommerce/AddToCartButton/AddToCartButton.module.css create mode 100644 src/components/ecommerce/AddToCartButton/AddToCartButton.stories.tsx create mode 100644 src/components/ecommerce/AddToCartButton/AddToCartButton.tsx create mode 100644 src/components/ecommerce/AddToCartButton/index.ts create mode 100644 src/components/ecommerce/CartItem/CartItem.module.css create mode 100644 src/components/ecommerce/CartItem/CartItem.stories.tsx create mode 100644 src/components/ecommerce/CartItem/CartItem.tsx create mode 100644 src/components/ecommerce/CartItem/index.ts create mode 100644 src/components/ecommerce/ProductBrief/ProductBrief.module.css create mode 100644 src/components/ecommerce/ProductBrief/ProductBrief.stories.tsx create mode 100644 src/components/ecommerce/ProductBrief/ProductBrief.tsx create mode 100644 src/components/ecommerce/ProductBrief/index.ts create mode 100644 src/components/ecommerce/ProductFull/ProductFull.module.css create mode 100644 src/components/ecommerce/ProductFull/ProductFull.stories.tsx create mode 100644 src/components/ecommerce/ProductFull/ProductFull.tsx create mode 100644 src/components/ecommerce/ProductFull/index.ts create mode 100644 src/components/ecommerce/index.ts create mode 100644 src/components/finance/Transaction/TransactionBrief/TransactionBrief.module.css create mode 100644 src/components/finance/Transaction/TransactionBrief/TransactionBrief.stories.tsx create mode 100644 src/components/finance/Transaction/TransactionBrief/TransactionBrief.tsx create mode 100644 src/components/finance/Transaction/TransactionBrief/index.ts create mode 100644 src/components/finance/Transaction/TransactionFull/TransactionFull.module.css create mode 100644 src/components/finance/Transaction/TransactionFull/TransactionFull.stories.tsx create mode 100644 src/components/finance/Transaction/TransactionFull/TransactionFull.tsx create mode 100644 src/components/finance/Transaction/TransactionFull/index.ts create mode 100644 src/components/finance/Transaction/index.ts create mode 100644 src/components/finance/index.ts create mode 100644 src/components/index.ts create mode 100644 src/components/layout/Header/Header.module.css create mode 100644 src/components/layout/Header/Header.stories.tsx create mode 100644 src/components/layout/Header/Header.tsx create mode 100644 src/components/layout/Header/index.ts create mode 100644 src/components/layout/Layout/Layout.module.css create mode 100644 src/components/layout/Layout/Layout.stories.tsx create mode 100644 src/components/layout/Layout/Layout.tsx create mode 100644 src/components/layout/Layout/index.ts create mode 100644 src/components/layout/index.ts create mode 100644 src/components/types.ts create mode 100644 src/components/ui/Logo/Logo.module.css create mode 100644 src/components/ui/Logo/Logo.stories.tsx create mode 100644 src/components/ui/Logo/Logo.tsx create mode 100644 src/components/ui/Logo/index.ts create mode 100644 src/components/ui/Modal/Modal.module.css create mode 100644 src/components/ui/Modal/Modal.stories.tsx create mode 100644 src/components/ui/Modal/Modal.tsx create mode 100644 src/components/ui/Modal/index.ts create mode 100644 src/components/ui/index.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0f5ba90b0..4bd19523d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,28 +34,28 @@ jobs: - name: Run tests and linter run: npm run lint && npm test - # Собираем приложение - - name: Build Application - run: npm run build - - # Публикуем приложение на Github Pages - - name: Deploy to Github Pages + # # Собираем приложение + # - name: Build Application + # run: npm run build + + # # Публикуем приложение на 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/.storybook/main.ts b/.storybook/main.ts index 3d1c9b2d5..eea5e2c0b 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,5 +1,5 @@ const config = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)", "../src/**/**/*.stories.@(js|jsx|ts|tsx)"], addons: [ "@storybook/addon-links", "@storybook/addon-essentials", diff --git a/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css b/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css new file mode 100644 index 000000000..b7a558c77 --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css @@ -0,0 +1,92 @@ + +.addButton { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + padding: 12px 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.addButton:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.addButton:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.counterWrapper { + display: flex; + align-items: center; + background: white; + border: 2px solid #e9ecef; + border-radius: 8px; + overflow: hidden; +} + +.changeButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: #f8f9fa; + border: none; + font-size: 18px; + font-weight: 600; + color: #495057; + cursor: pointer; + transition: all 0.2s ease; +} + +.changeButton:hover:not(:disabled) { + background: #e9ecef; + color: #212529; +} + +.changeButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.counter { + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 36px; + padding: 0 8px; + font-size: 16px; + font-weight: 600; + color: #212529; + background: white; +} + +/* Адаптивность */ +@media (max-width: 480px) { + .addButton { + padding: 10px 20px; + font-size: 13px; + } + + .changeButton { + width: 32px; + height: 32px; + font-size: 16px; + } + + .counter { + height: 32px; + min-width: 36px; + font-size: 14px; + } +} diff --git a/src/components/ecommerce/AddToCartButton/AddToCartButton.stories.tsx b/src/components/ecommerce/AddToCartButton/AddToCartButton.stories.tsx new file mode 100644 index 000000000..d313c318c --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/AddToCartButton.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import AddToCartButton from './AddToCartButton'; + +const meta: Meta = { + title: 'Components/E-commerce/AddToCartButton', + component: AddToCartButton, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + count: { + control: { type: 'number', min: 0, max: 10 }, + description: 'Количество товара в корзине', + }, + background: { + control: 'color', + description: 'Изменение цвета фона кнопки', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const InitialState: Story = { + args: { + count: 0, + }, + parameters: { + docs: { + description: { + story: 'Начальное состояние - кнопка "В корзину"', + }, + }, + }, +}; + +export const WithCount: Story = { + args: { + count: 3, + }, +}; \ No newline at end of file diff --git a/src/components/ecommerce/AddToCartButton/AddToCartButton.tsx b/src/components/ecommerce/AddToCartButton/AddToCartButton.tsx new file mode 100644 index 000000000..2a7ec0083 --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/AddToCartButton.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import s from './AddToCartButton.module.css'; +import { AddToCartButtonProps } from '../../types'; + +const AddToCartButton: React.FC = ({ count = 0, background }) => { + const handleAdd = () => alert('Товар добавлен в корзину'); + const handleIncrease = () => alert('Увеличить количество'); + const handleDecrease = () => alert('Уменьшить количество'); + if (count === 0) { + return ( + + ); + } + + return ( +
+ +
{count}
+ +
+ ); +}; + +export default AddToCartButton; \ No newline at end of file diff --git a/src/components/ecommerce/AddToCartButton/index.ts b/src/components/ecommerce/AddToCartButton/index.ts new file mode 100644 index 000000000..d9581ec31 --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/index.ts @@ -0,0 +1 @@ +export { default as AddToCartButton } from './AddToCartButton'; \ No newline at end of file diff --git a/src/components/ecommerce/CartItem/CartItem.module.css b/src/components/ecommerce/CartItem/CartItem.module.css new file mode 100644 index 000000000..e86dbece4 --- /dev/null +++ b/src/components/ecommerce/CartItem/CartItem.module.css @@ -0,0 +1,175 @@ +.itemContainer { + background: white; + border: 1px solid #e9ecef; + border-radius: 12px; + padding: 16px; + display: flex; + gap: 16px; + transition: all 0.2s ease; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +.itemContainer:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.imageContainer { + flex-shrink: 0; + width: 80px; + height: 80px; + border-radius: 8px; + overflow: hidden; + background: #f8f9fa; +} + +.productImage { + width: 100%; + height: 100%; + object-fit: contain; +} + +.details { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.headerRow { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.title { + font-size: 16px; + font-weight: 600; + color: #2c3e50; + margin: 0; + flex: 1; +} + +.removeBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s ease, transform 0.2s ease; +} + +.removeBtn:hover:not(:disabled) { + background: #e74c3c40; + border-color: #e74c3c; + transform: scale(1.1); +} + +.removeBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.description { + font-size: 14px; + color: #5d6d7e; + margin: 4px 0 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.footerRow { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + margin-top: auto; +} + +.quantity { + font-size: 14px; + color: #6c757d; +} + +.quantityValue { + font-weight: 600; + color: #495057; +} + +.priceInfo { + text-align: right; +} + +.unitPrice { + color: #5d6d7e; + font-size: 12px; + margin-bottom: 2px; +} + +.totalPrice { + font-size: 18px; + font-weight: 700; + color: #27ae60; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .itemContainer { + padding: 12px; + gap: 12px; + } + + .productImage { + width: 70px; + height: 70px; + } + + .name { + font-size: 15px; + } + + .description { + font-size: 13px; + } + + .footer { + gap: 12px; + } + + .totalPrice { + font-size: 16px; + } +} + +@media (max-width: 480px) { + .itemContainer { + flex-direction: column; + align-items: center; + text-align: center; + } + + .imageContainer { + width: 100%; + height: 120px; + align-self: center; + max-width: 120px; + } + + .footerRow { + flex-direction: column; + gap: 8px; + align-items: center; + } + + .priceInfo { + text-align: center; + } +} \ No newline at end of file diff --git a/src/components/ecommerce/CartItem/CartItem.stories.tsx b/src/components/ecommerce/CartItem/CartItem.stories.tsx new file mode 100644 index 000000000..b452776f3 --- /dev/null +++ b/src/components/ecommerce/CartItem/CartItem.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import CartItem from './CartItem'; +import { CartItemProps } from '../../types'; + +const meta: Meta = { + title: 'Components/E-commerce/CartItem', + component: CartItem, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + argTypes: { + item: { control: 'object' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const exampleItem: CartItemProps['item'] = { + name: 'Larry Carlton X6 Headless 6 Trans Black', + description: 'Headless Electric Guitar with 6 Strings', + price: 65162.53, + image: 'https://bdbo1.thomann.de/thumb/bdb3000/pics/bdbo/19832399.jpg', + category: 'Headless Guitars', + quantity: 1, +}; + +export const SingleItem: Story = { + args: { + item: exampleItem, + }, + parameters: { + docs: { + description: { + story: 'Пример с одним товаром в корзине', + }, + }, + }, +}; +export const MultipleQty: Story = { + args: { + item: { ...exampleItem, quantity: 3 }, + }, +}; \ No newline at end of file diff --git a/src/components/ecommerce/CartItem/CartItem.tsx b/src/components/ecommerce/CartItem/CartItem.tsx new file mode 100644 index 000000000..988d6bc54 --- /dev/null +++ b/src/components/ecommerce/CartItem/CartItem.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import s from './CartItem.module.css'; +import { CartItemProps } from '../../types'; + +const CartItem: React.FC = ({ item }) => { + const { name, description, price, image, quantity } = item; + const totalPrice = price * quantity; + + const handleRemove = () => alert(`Удалить ${name} из корзины`); + + return ( +
+
+ {name} +
+ +
+
+

{name}

+ +
+ +

{description}

+ +
+
+ Количество: {quantity} +
+
+
{price.toLocaleString('ru-RU')} ₽ за шт.
+
{totalPrice.toLocaleString('ru-RU')} ₽
+
+
+
+
+ ); +}; +export default CartItem; \ No newline at end of file diff --git a/src/components/ecommerce/CartItem/index.ts b/src/components/ecommerce/CartItem/index.ts new file mode 100644 index 000000000..cc887d528 --- /dev/null +++ b/src/components/ecommerce/CartItem/index.ts @@ -0,0 +1 @@ +export { default as CartItem } from './CartItem'; \ No newline at end of file diff --git a/src/components/ecommerce/ProductBrief/ProductBrief.module.css b/src/components/ecommerce/ProductBrief/ProductBrief.module.css new file mode 100644 index 000000000..6cef96a76 --- /dev/null +++ b/src/components/ecommerce/ProductBrief/ProductBrief.module.css @@ -0,0 +1,117 @@ +.productBrief { + background: white; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + border: 1px solid #f0f0f0; + transition: all 0.3s ease; + display: flex; + flex-direction: column; + height: 100%; +} + +.productBrief:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-4px); +} + +.imageContainer { + position: relative; + width: 100%; + height: 200px; + overflow: hidden; + background: #f8f9fa; +} + +.productImage { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.productBrief:hover .productImage { + transform: scale(1.05); +} + +.details { + padding: 16px; + display: flex; + flex-direction: column; + flex: 1; +} + +.title { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #2c3e50; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.description { + margin: 0 0 16px 0; + color: #5d6d7e; + font-size: 14px; + line-height: 1.4; + flex: 1; +} + +.footerRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: auto; +} + +.priceInfo { + font-size: 18px; + font-weight: 700; + color: #27ae60; +} +.unitPrice { + color: #5d6d7e; + font-size: 16px; + margin-bottom: 2px; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .imageContainer { + height: 180px; + } + + .content { + padding: 14px; + } + + .name { + font-size: 15px; + } + + .description { + font-size: 13px; + } + + .price { + font-size: 16px; + } +} + +@media (max-width: 480px) { + .footer { + flex-direction: column; + gap: 8px; + align-items: stretch; + } + + .price { + text-align: center; + } +} \ No newline at end of file diff --git a/src/components/ecommerce/ProductBrief/ProductBrief.stories.tsx b/src/components/ecommerce/ProductBrief/ProductBrief.stories.tsx new file mode 100644 index 000000000..22958885a --- /dev/null +++ b/src/components/ecommerce/ProductBrief/ProductBrief.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProductBrief from './ProductBrief'; +import { ProductBriefProps } from '../../types'; + +const meta: Meta = { + title: 'Components/E-commerce/ProductBrief', + component: ProductBrief, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + argTypes: { + product: { control: 'object' }, + maxDescriptionLength: { + control: { type: 'number', min: 20, max: 200, step: 10 }, + description: 'Максимальная длина описания', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const exampProductBrief: ProductBriefProps['product'] = { + name: 'Larry Carlton X6 Headless 6 Trans Black', + description: 'Headless Electric Guitar with 6 Strings', + price: 65162.53, + image: 'https://bdbo1.thomann.de/thumb/bdb3000/pics/bdbo/19832399.jpg', + category: 'Headless Guitars', +}; + +export const Default: Story = { + args: { + product: exampProductBrief, + maxDescriptionLength: 80, + }, +}; +export const LongText: Story = { + args: { + product: exampProductBrief, + maxDescriptionLength: 30, + }, +}; \ No newline at end of file diff --git a/src/components/ecommerce/ProductBrief/ProductBrief.tsx b/src/components/ecommerce/ProductBrief/ProductBrief.tsx new file mode 100644 index 000000000..1513d559a --- /dev/null +++ b/src/components/ecommerce/ProductBrief/ProductBrief.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import AddToCartButton from '../AddToCartButton/AddToCartButton'; +import { ProductBriefProps } from '../../types'; +import s from './ProductBrief.module.css'; + +const ProductBrief: React.FC = ({ product, maxDescriptionLength = 80 }) => { + const { name, description, price, image } = product; + + const truncatedDesc = + description.length > maxDescriptionLength + ? description.slice(0, maxDescriptionLength) + '...' + : description; + + return ( +
+
+ {name} +
+ +
+

{name}

+

+ {truncatedDesc} +

+ +
+
{price.toLocaleString('ru-RU')} ₽
+ +
+
+
+ ); +}; +export default ProductBrief; \ No newline at end of file diff --git a/src/components/ecommerce/ProductBrief/index.ts b/src/components/ecommerce/ProductBrief/index.ts new file mode 100644 index 000000000..c8e3a309c --- /dev/null +++ b/src/components/ecommerce/ProductBrief/index.ts @@ -0,0 +1 @@ +export { default as ProductBrief } from './ProductBrief'; \ No newline at end of file diff --git a/src/components/ecommerce/ProductFull/ProductFull.module.css b/src/components/ecommerce/ProductFull/ProductFull.module.css new file mode 100644 index 000000000..61f8fff7c --- /dev/null +++ b/src/components/ecommerce/ProductFull/ProductFull.module.css @@ -0,0 +1,162 @@ +.productFull { + background: white; + border-radius: 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + overflow: hidden; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + max-width: 1000px; + margin: 0 auto; +} + +.imageSection { + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +} + +.imageContainer { + width: 100%; + max-width: 400px; + aspect-ratio: 1; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.productImage { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.productImage:hover { + transform: scale(1.05); +} + +.details { + padding: 32px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.headerRow { + margin-bottom: 20px; +} + +.category { + display: inline-block; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.title { + margin: 0 0 16px 0; + font-size: 28px; + font-weight: 700; + color: #2c3e50; +} + +.unitPrice { + font-size: 26px; + font-weight: 800; + color: #000000; + margin-bottom: 8px; +} + +.description { + margin-bottom: 32px; + flex: 1; +} + +.descriptionTitle { + margin: 0 0 12px 0; + font-size: 18px; + font-weight: 600; + color: #34495e; +} + +.descriptionText { + margin: 0; + color: #5d6d7e; + font-size: 16px; + line-height: 1.6; +} +.bottomRow{ + display:flex; + justify-content:space-between; + align-items:center +} + +.actions { + display: flex; + gap: 16px; + align-items: center; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .productFull { + grid-template-columns: 1fr; + max-width: 600px; + } + + .imageSection { + padding: 24px; + order: 1; + } + + .contentSection { + padding: 24px; + order: 2; + } + + .title { + font-size: 24px; + } + + .unitPrice { + font-size: 28px; + } + + .descriptionTitle { + font-size: 16px; + } + + .descriptionText { + font-size: 15px; + } +} + +@media (max-width: 480px) { + .imageSection, + .contentSection { + padding: 16px; + } + + .title { + font-size: 20px; + } + + .unitPrice { + font-size: 24px; + } + + .categoryBadge { + font-size: 11px; + padding: 4px 8px; + } +} \ No newline at end of file diff --git a/src/components/ecommerce/ProductFull/ProductFull.stories.tsx b/src/components/ecommerce/ProductFull/ProductFull.stories.tsx new file mode 100644 index 000000000..261500208 --- /dev/null +++ b/src/components/ecommerce/ProductFull/ProductFull.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProductFull from './ProductFull'; +import { ProductFullProps } from '../../types'; + +const meta: Meta = { + title: 'Components/E-commerce/ProductFull', + component: ProductFull, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +const exampleProductFull: ProductFullProps['product'] = { + name: 'Larry Carlton X6 Headless 6 Trans Black', + description: 'Stirring into the innovative world of instruments, Sire produces its own headless guitars. The new X6 offers a combination of modern sleek design and exceptional functionality, constructed with a lightweight build (approximately 6–7 lbs.) to ensure playing at ease. The X6 delivers a powerful and dynamic sound, making it a perfect choice for any players who want to go trendy without compromising performance. Available in 6 and 7-string variants.', + price: 65162.53, + image: 'https://bdbo1.thomann.de/thumb/bdb3000/pics/bdbo/19832399.jpg', + category: 'Headless Guitars', +}; + +export const Default: Story = { + args: { + product: exampleProductFull, + }, + parameters: { + docs: { + description: { + story: 'Детальная страница смартфона с полным описанием', + }, + }, + }, +}; \ No newline at end of file diff --git a/src/components/ecommerce/ProductFull/ProductFull.tsx b/src/components/ecommerce/ProductFull/ProductFull.tsx new file mode 100644 index 000000000..f3dbadcab --- /dev/null +++ b/src/components/ecommerce/ProductFull/ProductFull.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import AddToCartButton from '../AddToCartButton/AddToCartButton'; +import { ProductFullProps } from '../../types'; +import s from './ProductFull.module.css'; + +const ProductFull: React.FC = ({ product }) => { + const { name, description, price, image, category } = product; + + return ( +
+
+
+ {name} +
+
+ +
+
+
{category}
+

{name}

+
+ +
+

Описание:

+

{description}

+
+ +
+
{price.toLocaleString('ru-RU')} ₽
+
+ +
+
+
+
+ ); +}; +export default ProductFull; \ No newline at end of file diff --git a/src/components/ecommerce/ProductFull/index.ts b/src/components/ecommerce/ProductFull/index.ts new file mode 100644 index 000000000..1aa8537a7 --- /dev/null +++ b/src/components/ecommerce/ProductFull/index.ts @@ -0,0 +1 @@ +export { default as ProductFull } from './ProductFull'; \ No newline at end of file diff --git a/src/components/ecommerce/index.ts b/src/components/ecommerce/index.ts new file mode 100644 index 000000000..e0522cb84 --- /dev/null +++ b/src/components/ecommerce/index.ts @@ -0,0 +1,4 @@ +export { AddToCartButton } from './AddToCartButton'; +export { CartItem } from './CartItem'; +export { ProductBrief } from './ProductBrief'; +export { ProductFull } from './ProductFull'; \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionBrief/TransactionBrief.module.css b/src/components/finance/Transaction/TransactionBrief/TransactionBrief.module.css new file mode 100644 index 000000000..da5160e5b --- /dev/null +++ b/src/components/finance/Transaction/TransactionBrief/TransactionBrief.module.css @@ -0,0 +1,91 @@ +.cardBrief { + background: #ffffff; + border-radius: 12px; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + border: 1px solid #f0f0f0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + transition: all 0.2s ease; +} + +.cardBrief:hover { + box-shadow: 0 4px 12rem rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 8px; +} + +.titleGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #000000; + line-height: 100%; +} + +.category { + display: inline-block; + background: linear-gradient(135deg, #4b6cb7, #182848); + color: #ffffff; + padding: 4px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + width: fit-content; +} + +.amount { + font-size: 16px; + font-weight: bold; + white-space: nowrap; +} + +.income { + color: #2fb307; +} + +.expense { + color: #f52c16; +} + +.description { + margin: 10px 0 0 0; + font-size: 14px; + color: #333; + line-height: 1.4; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .header { + flex-direction: column; + margin-bottom: 10px; + } + + .amount { + font-size: 15px; + } + + .title { + font-size: 16px; + } +} + +@media (max-width: 480px) { + .cardBrief { + padding: 12px; + } +} \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionBrief/TransactionBrief.stories.tsx b/src/components/finance/Transaction/TransactionBrief/TransactionBrief.stories.tsx new file mode 100644 index 000000000..6bc36fac8 --- /dev/null +++ b/src/components/finance/Transaction/TransactionBrief/TransactionBrief.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import TransactionBrief from './TransactionBrief'; + +const meta: Meta = { + title: 'Components/Finance/Transaction/TransactionBrief', + component: TransactionBrief, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + argTypes: { + amount: { + control: { type: 'number' }, + description: 'Сумма операции', + }, + category: { + control: 'text', + description: 'Категория', + }, + title: { + control: 'text', + description: 'Название операции', + }, + description: { + control: 'text', + description: 'Описание операции', + }, + maxDescriptionLength: { + control: { type: 'number', min: 10, max: 200, step: 10 }, + description: 'Максимальная длина описания', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const IncomeTransaction: Story = { + args: { + amount: 200000, + category: 'Зарплата', + title: 'Перевод от работодателя', + description: 'Получена зарплата за август.', + maxDescriptionLength: 60, + }, +}; + +export const ExpenseTransaction: Story = { + args: { + amount: -2750, + category: 'Еда', + title: 'Покупка в магазине', + description: 'Купил продукты в супермаркете на неделю: овощи, фрукты, мясо, хлеб и молочные продукты.', + maxDescriptionLength: 60, + }, +}; \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionBrief/TransactionBrief.tsx b/src/components/finance/Transaction/TransactionBrief/TransactionBrief.tsx new file mode 100644 index 000000000..1f3b813fb --- /dev/null +++ b/src/components/finance/Transaction/TransactionBrief/TransactionBrief.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import s from './TransactionBrief.module.css'; +import { TransactionBrief as TransactionBriefProps } from '../../../types'; + +const TransactionBrief: React.FC = ({ + amount, + category, + title, + description, + maxDescriptionLength = 60, +}) => { + const isPositive = amount > 0; + const sign = isPositive ? '+' : '-'; + const formattedAmount = `${sign}${Math.abs(amount).toLocaleString('ru-RU')} ₽`; + const truncatedDesc = description.length > maxDescriptionLength + ? description.slice(0, maxDescriptionLength) + '...' + : description; + + return ( +
+
+
+

{title}

+ {category} +
+
+ {formattedAmount} +
+
+ {description &&

{truncatedDesc}

} +
+ ); +}; +export default TransactionBrief; \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionBrief/index.ts b/src/components/finance/Transaction/TransactionBrief/index.ts new file mode 100644 index 000000000..18c87fe25 --- /dev/null +++ b/src/components/finance/Transaction/TransactionBrief/index.ts @@ -0,0 +1 @@ +export { default as TransactionBrief } from './TransactionBrief'; \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionFull/TransactionFull.module.css b/src/components/finance/Transaction/TransactionFull/TransactionFull.module.css new file mode 100644 index 000000000..dfa819fcc --- /dev/null +++ b/src/components/finance/Transaction/TransactionFull/TransactionFull.module.css @@ -0,0 +1,166 @@ +.cardFull { + background: #ffffff; + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + border: 1px solid #f0f0f0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + transition: all 0.2s ease; +} + +.cardFull:hover { + box-shadow: 0 4px 12rem rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.titleGroup { + display: flex; + flex-direction: column; + gap: 8px; + flex-grow: 1; +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #000000; + line-height: 100%; +} + +.meta { + display: flex; + gap: 12px; + align-items: center; + margin-top: 4px; + font-size: 13px; + color: #6b6b6b; +} + +.category { + display: inline-block; + background: linear-gradient(135deg, #4b6cb7, #182848); + color: #ffffff; + padding: 4px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + width: fit-content; +} + +.date { + color: #7f8c8d; + font-size: 14px; + font-weight: 500; +} + +.actions { + display: flex; + align-items: center; + gap: 12px; +} + +.amount { + font-size: 16px; + font-weight: bold; + white-space: nowrap; +} + +.income { + color: #2fb307; +} + +.expense { + color: #f52c16; +} + +.editButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + font-size: 18px; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.editButton:hover:not(:disabled) { + background: #e9ecef; + border-color: #adb5bd; + transform: scale(1.05); +} + +.editButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.descriptionBlock { + margin-top: 16px; + border-top: 1px solid #ecf0f1; +} + +.descriptionTitle { + font-size: 15px; + font-weight: 500; + margin-bottom: 4px; + color: #34495e; +} + +.descriptionText { + margin: 0; + color: #5d6d7e; + font-size: 14px; + line-height: 1.5; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 12px; + } + + .amount { + font-size: 16px; + } + + .title { + font-size: 18px; + } + + .meta { + gap: 8px; + } +} + +@media (max-width: 480px) { + .cardFull { + padding: 12px; + } + + .actions { + gap: 8px; + } + + .editButton { + width: 32px; + height: 32px; + font-size: 14px; + } +} \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionFull/TransactionFull.stories.tsx b/src/components/finance/Transaction/TransactionFull/TransactionFull.stories.tsx new file mode 100644 index 000000000..bb527cc64 --- /dev/null +++ b/src/components/finance/Transaction/TransactionFull/TransactionFull.stories.tsx @@ -0,0 +1,47 @@ +import TransactionFull from './TransactionFull'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Components/Finance/Transaction/TransactionFull', + component: TransactionFull, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + argTypes: { + amount: { control: 'number', description: 'Сумма операции' }, + category: { control: 'text', description: 'Категория' }, + title: { control: 'text', description: 'Название операции' }, + description: { control: 'text', description: 'Описание операции' }, + date: { control: 'date', description: 'Дата операции' }, + maxDescriptionLength: { + control: { type: 'number', min: 10, max: 200, step: 10 }, + description: 'Максимальная длина описания', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const IncomeTransaction: Story = { + args: { + amount: 200000, + category: 'Зарплата', + title: 'Перевод от работодателя', + description: 'Зарплата за август 2025 года, включая премию за успешный проект.', + date: new Date('2025-08-01').toISOString(), + maxDescriptionLength: 60, + }, +}; + +export const ExpenseTransaction: Story = { + args: { + amount: -4600, + category: 'Коммунальные услуги', + title: 'Оплата ЖКХ', + description: 'Платёж за июль: электричество, вода, газ и вывоз мусора.', + date: new Date('2025-07-31').toISOString(), + maxDescriptionLength: 60, + }, +}; \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionFull/TransactionFull.tsx b/src/components/finance/Transaction/TransactionFull/TransactionFull.tsx new file mode 100644 index 000000000..d350ad5a1 --- /dev/null +++ b/src/components/finance/Transaction/TransactionFull/TransactionFull.tsx @@ -0,0 +1,58 @@ +/* eslint-disable prettier/prettier */ +import React from 'react'; +import s from './TransactionFull.module.css'; +import { TransactionFull as TransactionFullProps } from '../../../types'; + +const TransactionFull: React.FC = ({ + amount, + category, + title, + description, + date, + maxDescriptionLength = 60, +}) => { + const isIncome = amount > 0; + const sign = isIncome ? '+' : '-'; + const formattedAmount = `${sign}${Math.abs(amount).toLocaleString('ru-RU')} ₽`; + const formattedDate = new Date(date).toLocaleDateString('ru-RU', { + day: '2-digit', + month: 'long', + year: 'numeric', + }); + const truncatedDesc = description.length > maxDescriptionLength + ? description.slice(0, maxDescriptionLength) + '...' + : description; + + return ( +
+
+
+

{title}

+
+ {category} + {formattedDate} +
+
+
+
+ {formattedAmount} +
+ +
+
+ {description && ( +
+

Описание:

+

{truncatedDesc}

+
+ )} +
+ ); +}; +export default TransactionFull; \ No newline at end of file diff --git a/src/components/finance/Transaction/TransactionFull/index.ts b/src/components/finance/Transaction/TransactionFull/index.ts new file mode 100644 index 000000000..5849077d5 --- /dev/null +++ b/src/components/finance/Transaction/TransactionFull/index.ts @@ -0,0 +1 @@ +export { default as TransactionFull } from './TransactionFull'; \ No newline at end of file diff --git a/src/components/finance/Transaction/index.ts b/src/components/finance/Transaction/index.ts new file mode 100644 index 000000000..6e6f019ab --- /dev/null +++ b/src/components/finance/Transaction/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable prettier/prettier */ +export { TransactionBrief } from './TransactionBrief'; +export { TransactionFull } from './TransactionFull'; \ No newline at end of file diff --git a/src/components/finance/index.ts b/src/components/finance/index.ts new file mode 100644 index 000000000..b3f624dfb --- /dev/null +++ b/src/components/finance/index.ts @@ -0,0 +1,2 @@ +export { TransactionBrief } from './Transaction/TransactionBrief'; +export { TransactionFull } from './Transaction/TransactionFull'; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 000000000..fa3183bd7 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,21 @@ +/* eslint-disable prettier/prettier */ +// General Components +export { Header } from './layout/Header'; +export { Layout } from './layout/Layout'; + +// UI Components +export { Logo } from './ui/Logo'; +export { Modal } from './ui/Modal'; + +// E-commerce Components +export { AddToCartButton } from './ecommerce/AddToCartButton'; +export { CartItem } from './ecommerce/CartItem'; +export { ProductBrief } from './ecommerce/ProductBrief'; +export { ProductFull } from './ecommerce/ProductFull'; + +// Transaction Components +export { TransactionBrief } from './finance/Transaction/TransactionBrief'; +export { TransactionFull } from './finance/Transaction/TransactionFull'; + +// Types +export * from './types'; \ No newline at end of file diff --git a/src/components/layout/Header/Header.module.css b/src/components/layout/Header/Header.module.css new file mode 100644 index 000000000..3ec5eb6d0 --- /dev/null +++ b/src/components/layout/Header/Header.module.css @@ -0,0 +1,29 @@ +.headerWrapper { + width: 100%; + position: sticky; + top: 0; + background-color: #ffffff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + z-index: 1000; +} + +.headerContainer { + max-width: 1200px; + margin: 0 auto; + padding: 1rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.logoBlock { + display: flex; + align-items: center; +} + +.navContent { + display: flex; + gap: 1.5rem; + font-weight: 500; + color: #333; +} \ No newline at end of file diff --git a/src/components/layout/Header/Header.stories.tsx b/src/components/layout/Header/Header.stories.tsx new file mode 100644 index 000000000..e70d2b145 --- /dev/null +++ b/src/components/layout/Header/Header.stories.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import Header from './Header'; + +const meta: Meta = { + title: 'Components/Layout/Header', + component: Header, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Заголовок с логотипом и кастомным контентом внутри. Поддерживает HTML для навигации.', + }, + }, + }, + argTypes: { + children: { + control: 'text', + name: 'Children HTML', + description: 'Дополнительный контент в правой части хедера', + }, + className: { + control: 'text', + description: 'Дополнительные CSS классы', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + className: '', + }, +}; + +const Template: Story = (args) => ( +
+
+
+); + +export const Playground = Template.bind({}); +Playground.args = { + className: '', + children: ``, +}; \ No newline at end of file diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx new file mode 100644 index 000000000..758c72ebf --- /dev/null +++ b/src/components/layout/Header/Header.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Logo from '../../ui/Logo/Logo'; +import { HeaderProps } from '../../types'; +import s from './Header.module.css'; + + +const Header: React.FC = ({ children, className }) => { + return ( +
+
+
+ +
+ {children &&
{children}
} +
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/src/components/layout/Header/index.ts b/src/components/layout/Header/index.ts new file mode 100644 index 000000000..2d319850d --- /dev/null +++ b/src/components/layout/Header/index.ts @@ -0,0 +1 @@ +export { default as Header } from './Header'; \ No newline at end of file diff --git a/src/components/layout/Layout/Layout.module.css b/src/components/layout/Layout/Layout.module.css new file mode 100644 index 000000000..4ac80a339 --- /dev/null +++ b/src/components/layout/Layout/Layout.module.css @@ -0,0 +1,28 @@ +.layoutWrapper { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #f8f9fa; +} + +.mainContent { + flex: 1; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .mainContent { + padding: 1rem; + } +} + +@media (max-width: 480px) { + .mainContent { + padding: 0.8rem; + } +} \ No newline at end of file diff --git a/src/components/layout/Layout/Layout.stories.tsx b/src/components/layout/Layout/Layout.stories.tsx new file mode 100644 index 000000000..5a5c4876f --- /dev/null +++ b/src/components/layout/Layout/Layout.stories.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import Layout from './Layout'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Components/Layout/Layout', + component: Layout, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + argTypes: { + children: { + control: 'text', + description: 'Основной контент, отображаемый под шапкой', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const defaultChildren = ( +
+

Добро пожаловать в интерфейс OTUS

+

Этот макет включает прилипший Header с логотипом и областью контента ниже.

+

Вы можете использовать этот Layout для страниц дашбордов, профиля, каталога и др.

+
+); + +const longChildren = ( +
+

Прокрутка и фиксированный Header

+

Ниже находятся секции, каждая с уникальным содержимым.

+ {Array.from({ length: 5 }, (_, i) => ( +
+

Секция {i + 1}

+

+ Это демонстрация длинного контента. Прокрутите вниз и убедитесь, что Header остаётся на месте. +

+
+ ))} +
+); + +export const Default: Story = { + args: { + children: defaultChildren, + }, +}; + +export const LongContent: Story = { + args: { + children: longChildren, + }, +}; \ No newline at end of file diff --git a/src/components/layout/Layout/Layout.tsx b/src/components/layout/Layout/Layout.tsx new file mode 100644 index 000000000..c74179b65 --- /dev/null +++ b/src/components/layout/Layout/Layout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { LayoutProps } from '../../types'; +import { Header } from '../Header'; +import s from './Layout.module.css'; + +const Layout: React.FC = ({ children, className }) => { + return ( +
+
+
+ {children} +
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/src/components/layout/Layout/index.ts b/src/components/layout/Layout/index.ts new file mode 100644 index 000000000..65fbebe83 --- /dev/null +++ b/src/components/layout/Layout/index.ts @@ -0,0 +1 @@ +export { default as Layout } from './Layout'; \ No newline at end of file diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 000000000..121ddb379 --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,2 @@ +export { Header } from './Header'; +export { Layout } from './Layout'; \ No newline at end of file diff --git a/src/components/types.ts b/src/components/types.ts new file mode 100644 index 000000000..9bad605a7 --- /dev/null +++ b/src/components/types.ts @@ -0,0 +1,79 @@ +import { ReactNode } from 'react'; + +// General component interfaces +export interface HeaderProps { + children?: ReactNode; + className?: string; +} +export interface LayoutProps { + children: ReactNode; + className?: string; +} + +// UI component interfaces + +export interface LogoProps { + size?: 'small' | 'medium' | 'large'; + children?: React.ReactNode; +} + +export interface ButtonProps { + primary?: boolean; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; + label: string; + onClick?: () => void; +} + +export interface ModalProps { + visible: boolean; + onClose: () => void; + children?: React.ReactNode; +} + +// Transaction component +export interface Transaction { + amount: number; + category: string; + title: string; + description: string; +} + +export interface TransactionBrief extends Transaction { + maxDescriptionLength?: number; +} + +export interface TransactionFull extends TransactionBrief { + date: string; +} + +// Ecommerce component +export interface Product { + name: string; + description: string; + price: number; + image: string; + category: string; +} + +export interface CartItem extends Product { + quantity: number; +} + +export interface AddToCartButtonProps { + count: number; + background?: string; +} + +export interface ProductBriefProps { + product: Product; + maxDescriptionLength?: number; +} + +export interface ProductFullProps { + product: Product; +} + +export interface CartItemProps { + item: CartItem; +} \ No newline at end of file diff --git a/src/components/ui/Logo/Logo.module.css b/src/components/ui/Logo/Logo.module.css new file mode 100644 index 000000000..0fb69c6a4 --- /dev/null +++ b/src/components/ui/Logo/Logo.module.css @@ -0,0 +1,52 @@ +.logoContainer { + display: flex; + align-items: center; + gap: 8px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + user-select: none; +} + +.logoCircle { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #4b6cb7, #182848); +} + +.logoText { + font-weight: 600; + font-size: 1.5rem; + color: #333; +} + +/* Размеры */ +.small .logoCircle { + width: 20px; + height: 20px; +} + +.small .logoText { + font-size: 1rem; +} + +.medium .logoCircle { + width: 28px; + height: 28px; +} + +.medium .logoText { + font-size: 1.25rem; +} + +.large .logoCircle { + width: 36px; + height: 36px; +} + +.large .logoText { + font-size: 1.75rem; +} \ No newline at end of file diff --git a/src/components/ui/Logo/Logo.stories.tsx b/src/components/ui/Logo/Logo.stories.tsx new file mode 100644 index 000000000..ffeda95de --- /dev/null +++ b/src/components/ui/Logo/Logo.stories.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import Logo from './Logo'; + +const meta: Meta = { + title: 'Components/UI/Logo', + component: Logo, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + size: { + control: { type: 'select' }, + options: ['small', 'medium', 'large'], + description: 'Размер логотипа', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + size: 'medium', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + }, +}; +export const Medium: Story = { + args: { + size: 'medium', + }, +}; +export const Large: Story = { + args: { + size: 'large', + }, +}; \ No newline at end of file diff --git a/src/components/ui/Logo/Logo.tsx b/src/components/ui/Logo/Logo.tsx new file mode 100644 index 000000000..217c72685 --- /dev/null +++ b/src/components/ui/Logo/Logo.tsx @@ -0,0 +1,14 @@ +import React, { FC } from 'react'; +import s from './Logo.module.css'; +import { LogoProps } from '../../types'; + +const Logo: React.FC = ({ size = 'medium' }) => { + return ( +
+
+ MyBrand +
+ ); +}; + +export default Logo; diff --git a/src/components/ui/Logo/index.ts b/src/components/ui/Logo/index.ts new file mode 100644 index 000000000..7b6a8b1bf --- /dev/null +++ b/src/components/ui/Logo/index.ts @@ -0,0 +1 @@ +export { default as Logo } from './Logo'; \ No newline at end of file diff --git a/src/components/ui/Modal/Modal.module.css b/src/components/ui/Modal/Modal.module.css new file mode 100644 index 000000000..2ebd5da89 --- /dev/null +++ b/src/components/ui/Modal/Modal.module.css @@ -0,0 +1,55 @@ +/* Modal.module.css */ + +.backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} + +.container { + background-color: #ffffff; + padding: 2rem; + border-radius: 16px; + max-width: 500px; + width: 90%; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + position: relative; +} + +.dismiss { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #333; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s, color 0.2s; +} + +.closeButton:hover { + background-color: #f5f5f5; + color: #333; +} + +.content { + margin-top: 1.5rem; + color: #333; + font-size: 1rem; + line-height: 1.6; +} \ No newline at end of file diff --git a/src/components/ui/Modal/Modal.stories.tsx b/src/components/ui/Modal/Modal.stories.tsx new file mode 100644 index 000000000..92bb98435 --- /dev/null +++ b/src/components/ui/Modal/Modal.stories.tsx @@ -0,0 +1,47 @@ +// Modal.stories.tsx +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import Modal from './Modal'; + +const meta: Meta = { + title: 'Components/UI/Modal', + component: Modal, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + argTypes: { + visible: { + control: 'boolean', + description: 'Управляет видимостью модального окна', + }, + onClose: { action: 'closed' }, + children: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + visible: true, + children: 'Пример содержимого модального окна' + }, +}; + +export const WithToggle: Story = { + render: (args) => { + const [open, setOpen] = useState(true); + + return ( + <> + + setOpen(false)} /> + + ); + }, + args: { + children: 'Это модальное окно можно закрыть нажатием на крестик' + } +}; \ No newline at end of file diff --git a/src/components/ui/Modal/Modal.tsx b/src/components/ui/Modal/Modal.tsx new file mode 100644 index 000000000..614fa5a44 --- /dev/null +++ b/src/components/ui/Modal/Modal.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import s from './Modal.module.css'; +import { ModalProps } from '../../types'; + +const Modal: React.FC = ({ visible, children, onClose }) => { + if (!visible) return null; + + return ( +
+
+ +
{children}
+
+
+ ); +}; +export default Modal; \ No newline at end of file diff --git a/src/components/ui/Modal/index.ts b/src/components/ui/Modal/index.ts new file mode 100644 index 000000000..48ca212d7 --- /dev/null +++ b/src/components/ui/Modal/index.ts @@ -0,0 +1 @@ +export { default as Modal } from './Modal'; \ No newline at end of file diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 000000000..a66162964 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,2 @@ +export { Logo } from './Logo'; +export { Modal } from './Modal'; \ No newline at end of file From cc24c2d1ce1d2cbf44cb1442e41e7ab45a6ba4cc Mon Sep 17 00:00:00 2001 From: Ivan Komrakov Date: Fri, 8 Aug 2025 04:55:33 +0300 Subject: [PATCH 3/9] Fix: Error Header stories --- .../layout/Header/Header.stories.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/layout/Header/Header.stories.tsx b/src/components/layout/Header/Header.stories.tsx index e70d2b145..ddde81835 100644 --- a/src/components/layout/Header/Header.stories.tsx +++ b/src/components/layout/Header/Header.stories.tsx @@ -36,17 +36,22 @@ export const Default: Story = { }, }; -const Template: Story = (args) => ( -
-
-
-); - -export const Playground = Template.bind({}); -Playground.args = { - className: '', - children: ``, +export const Playground: Story = { + args: { + className: '', + children: ``, + }, + render: (args) => { + const { className, children } = args; + // Convert children to HTML if it's a string + const html = typeof children === 'string' ? children : ''; + return ( +
+
+
+ ); + }, }; \ No newline at end of file From f13e93adf7c9f094a41c9862e81d6b35ca6c008b Mon Sep 17 00:00:00 2001 From: Ivan Komrakov Date: Fri, 8 Aug 2025 19:48:19 +0300 Subject: [PATCH 4/9] Refactor: modify typescript code --- package.json | 4 ++-- src/components/types.ts | 17 +++++++---------- src/components/ui/Logo/Logo.stories.tsx | 10 +++++----- src/components/ui/Logo/Logo.tsx | 4 ++-- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index fadf9ed05..45db4dba5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "scripts": { "start": "webpack serve --mode development", "build": "webpack --mode production", - "predeploy": "npm run build", - "deploy": "npx gh-pages -d dist", + "predeploy": "npm run build-storybook", + "deploy": "npx gh-pages -d storybook-static", "test": "jest src", "lint": "eslint src --fix", "storybook": "storybook dev -p 6006", diff --git a/src/components/types.ts b/src/components/types.ts index 9bad605a7..ffabee68a 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -11,24 +11,21 @@ export interface LayoutProps { } // UI component interfaces +export enum Sizes { + small = 'small', + medium = 'medium', + large = 'large', +} export interface LogoProps { - size?: 'small' | 'medium' | 'large'; + size?: Sizes; children?: React.ReactNode; } -export interface ButtonProps { - primary?: boolean; - backgroundColor?: string; - size?: 'small' | 'medium' | 'large'; - label: string; - onClick?: () => void; -} - export interface ModalProps { visible: boolean; - onClose: () => void; children?: React.ReactNode; + onClose: () => void; } // Transaction component diff --git a/src/components/ui/Logo/Logo.stories.tsx b/src/components/ui/Logo/Logo.stories.tsx index ffeda95de..231c922e0 100644 --- a/src/components/ui/Logo/Logo.stories.tsx +++ b/src/components/ui/Logo/Logo.stories.tsx @@ -1,6 +1,6 @@ -import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import Logo from './Logo'; +import { Sizes } from '../../types'; const meta: Meta = { title: 'Components/UI/Logo', @@ -23,22 +23,22 @@ type Story = StoryObj; export const Default: Story = { args: { - size: 'medium', + size: Sizes.medium, }, }; export const Small: Story = { args: { - size: 'small', + size: Sizes.small, }, }; export const Medium: Story = { args: { - size: 'medium', + size: Sizes.medium, }, }; export const Large: Story = { args: { - size: 'large', + size: Sizes.large, }, }; \ No newline at end of file diff --git a/src/components/ui/Logo/Logo.tsx b/src/components/ui/Logo/Logo.tsx index 217c72685..5ac8a4edd 100644 --- a/src/components/ui/Logo/Logo.tsx +++ b/src/components/ui/Logo/Logo.tsx @@ -1,8 +1,8 @@ import React, { FC } from 'react'; import s from './Logo.module.css'; -import { LogoProps } from '../../types'; +import { LogoProps, Sizes } from '../../types'; -const Logo: React.FC = ({ size = 'medium' }) => { +const Logo: React.FC = ({ size = Sizes.medium }) => { return (
From 9dd738151da5b1e50b35dc6b731ef9a4c8b0632d Mon Sep 17 00:00:00 2001 From: Ivan Komrakov Date: Wed, 13 Aug 2025 03:40:18 +0300 Subject: [PATCH 5/9] feat: (theme) add global ThemeProvider to Storybook decorators feat: (i18n) wrap stories with LocalizationProvider refactor: (header) memoize Header and group controls chore: (storybook) migrate Header stories to CSF3 render feat: (modal) autofocus input on open via useRef perf: (ui) memoize LanguageToggle and ThemeToggle --- .storybook/preview.ts | 17 ++ package-lock.json | 146 ++++++++++++++---- package.json | 4 +- src/app/{App.css => App.module.css} | 0 src/app/App.tsx | 50 ++---- src/components/index.ts | 2 + .../layout/Header/Header.module.css | 52 +++++-- .../layout/Header/Header.stories.tsx | 14 +- src/components/layout/Header/Header.tsx | 22 ++- .../layout/Layout/Layout.stories.tsx | 30 ++-- src/components/layout/Layout/Layout.tsx | 4 +- .../LanguageToggle/LanguageToggle.module.css | 9 ++ .../LanguageToggle/LanguageToggle.stories.tsx | 22 +++ .../ui/LanguageToggle/LanguageToggle.tsx | 21 +++ src/components/ui/LanguageToggle/index.ts | 1 + src/components/ui/Logo/Logo.module.css | 2 +- src/components/ui/Logo/Logo.tsx | 4 +- .../ModalStateDemo/ModalStateDemo.module.css | 23 +++ .../ModalStateDemo/ModalStateDemo.stories.tsx | 24 +++ .../ui/ModalStateDemo/ModalStateDemo.tsx | 31 ++++ src/components/ui/ModalStateDemo/index.ts | 1 + .../ui/ThemeToggle/ThemeToggle.module.css | 10 ++ .../ui/ThemeToggle/ThemeToggle.stories.tsx | 24 +++ src/components/ui/ThemeToggle/ThemeToggle.tsx | 20 +++ src/components/ui/ThemeToggle/index.ts | 1 + src/components/ui/index.ts | 4 +- .../LocalizationProvider.tsx | 9 ++ .../providers/LocalizationProvider/i18n.ts | 32 ++++ .../providers/LocalizationProvider/index.ts | 1 + .../providers/ThemeProvider/ThemeProvider.tsx | 40 +++++ src/shared/providers/ThemeProvider/theme.css | 17 ++ tsconfig.json | 2 +- 32 files changed, 537 insertions(+), 102 deletions(-) rename src/app/{App.css => App.module.css} (100%) create mode 100644 src/components/ui/LanguageToggle/LanguageToggle.module.css create mode 100644 src/components/ui/LanguageToggle/LanguageToggle.stories.tsx create mode 100644 src/components/ui/LanguageToggle/LanguageToggle.tsx create mode 100644 src/components/ui/LanguageToggle/index.ts create mode 100644 src/components/ui/ModalStateDemo/ModalStateDemo.module.css create mode 100644 src/components/ui/ModalStateDemo/ModalStateDemo.stories.tsx create mode 100644 src/components/ui/ModalStateDemo/ModalStateDemo.tsx create mode 100644 src/components/ui/ModalStateDemo/index.ts create mode 100644 src/components/ui/ThemeToggle/ThemeToggle.module.css create mode 100644 src/components/ui/ThemeToggle/ThemeToggle.stories.tsx create mode 100644 src/components/ui/ThemeToggle/ThemeToggle.tsx create mode 100644 src/components/ui/ThemeToggle/index.ts create mode 100644 src/shared/providers/LocalizationProvider/LocalizationProvider.tsx create mode 100644 src/shared/providers/LocalizationProvider/i18n.ts create mode 100644 src/shared/providers/LocalizationProvider/index.ts create mode 100644 src/shared/providers/ThemeProvider/ThemeProvider.tsx create mode 100644 src/shared/providers/ThemeProvider/theme.css diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 1c372b694..fb91ff502 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,8 @@ import type { Preview } from "@storybook/react"; +import React from 'react'; +import { ThemeProvider } from '../src/shared/providers/ThemeProvider/ThemeProvider'; +import LocalizationProvider from '../src/shared/providers/LocalizationProvider/LocalizationProvider'; +import '../src/shared/providers/ThemeProvider/theme.css'; const preview: Preview = { parameters: { @@ -9,7 +13,20 @@ const preview: Preview = { date: /Date$/, }, }, + layout: 'fullscreen', }, + decorators: [ + (Story) => + React.createElement( + LocalizationProvider, + null, + React.createElement( + ThemeProvider, + null, + React.createElement('div', { style: { padding: 16 } }, React.createElement(Story)) + ) + ), + ], }; export default preview; diff --git a/package-lock.json b/package-lock.json index f8dd96c7c..9a22f9d9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "clsx": "^1.2.1", + "i18next": "^25.3.2", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-i18next": "^15.6.1" }, "devDependencies": { "@babel/core": "^7.22.1", @@ -2033,13 +2035,10 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", - "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", + "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -13615,6 +13614,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -13795,6 +13803,37 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -20010,6 +20049,32 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/react-i18next": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.1.tgz", + "integrity": "sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -20266,12 +20331,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, "node_modules/regenerator-transform": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", @@ -22293,7 +22352,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22798,6 +22857,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -25003,13 +25071,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", - "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.11" - } + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", + "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==" }, "@babel/template": { "version": "7.22.15", @@ -33556,6 +33620,14 @@ "terser": "^5.10.0" } }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, "html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -33677,6 +33749,14 @@ "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", "dev": true }, + "i18next": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", + "requires": { + "@babel/runtime": "^7.27.6" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -38127,6 +38207,15 @@ } } }, + "react-i18next": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.1.tgz", + "integrity": "sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==", + "requires": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -38305,12 +38394,6 @@ "regenerate": "^1.4.2" } }, - "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, "regenerator-transform": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", @@ -39835,7 +39918,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==", - "dev": true + "devOptional": true }, "ufo": { "version": "1.4.0", @@ -40195,6 +40278,11 @@ "unist-util-stringify-position": "^3.0.0" } }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, "w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 45db4dba5..a7c76eff2 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,9 @@ }, "dependencies": { "clsx": "^1.2.1", + "i18next": "^25.3.2", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-i18next": "^15.6.1" } } diff --git a/src/app/App.css b/src/app/App.module.css similarity index 100% rename from src/app/App.css rename to src/app/App.module.css diff --git a/src/app/App.tsx b/src/app/App.tsx index 257db87f5..d110eb711 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,42 +1,24 @@ import React from 'react'; +import { Header } from '../components/layout/Header'; +import { ThemeProvider } from '../shared/providers/ThemeProvider/ThemeProvider'; +import { default as LocalizationProvider } from '../shared/providers/LocalizationProvider/LocalizationProvider'; import logo from './logo.svg'; -import './App.css'; +import s from './App.module.css'; +import '../shared/providers/ThemeProvider/theme.css'; function App() { return ( -
-
- logo -
-

Иван Комраков

-

- Цель: - Хочу приобрести/структурировать новые знания и навыки о React, чтобы применить их на работе. -

-

- Технологии: -

    -
  • JavaScript (ES6), jQuery, HTML, CSS3 (препроцессоры SCSS/SASS), Bootstrap, PHP 7.3+, Laravel 8+ .
  • -
  • Частичная работа с React 18, Node.js.
  • -
  • Rest API.
  • -
  • Сборщики: Laravel mix. React vite/WebPack .
  • -
  • Composer, NPM, Kubernets, Docker.
  • -
  • SQL(MySQL,MariaDB), MongoDB.
  • -
  • Git, GitLab, GitLab CI/CD.
  • -
  • CMS WordPress.
  • -
  • Figma, Jira.
  • -
-

-

- Опыт: - Имею опыт работы в области веб-разработки (профессиональный опыт 7 лет), обладаю знаниями и навыками верстки, Frontend и Backend-технологий. -

-

- Контакты: - kmrakvi@gmail.com -

-
-
+
+ + +
+
+
+ logo +
+
+
+
); } diff --git a/src/components/index.ts b/src/components/index.ts index fa3183bd7..a667dd9a5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,8 @@ export { Layout } from './layout/Layout'; // UI Components export { Logo } from './ui/Logo'; export { Modal } from './ui/Modal'; +export { LanguageToggle } from './ui/LanguageToggle'; +export { ThemeToggle } from './ui/ThemeToggle'; // E-commerce Components export { AddToCartButton } from './ecommerce/AddToCartButton'; diff --git a/src/components/layout/Header/Header.module.css b/src/components/layout/Header/Header.module.css index 3ec5eb6d0..b4b7fe651 100644 --- a/src/components/layout/Header/Header.module.css +++ b/src/components/layout/Header/Header.module.css @@ -1,29 +1,55 @@ .headerWrapper { - width: 100%; position: sticky; top: 0; - background-color: #ffffff; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); - z-index: 1000; + z-index: 10; + background: var(--panel); + border-bottom: 1px solid var(--border); } .headerContainer { - max-width: 1200px; - margin: 0 auto; - padding: 1rem 2rem; - display: flex; + display: grid; + grid-template-columns: auto 1fr auto; align-items: center; - justify-content: space-between; + gap: 16px; + padding: 10px 16px; } .logoBlock { display: flex; align-items: center; + gap: 10px; +} + +.title { + font-weight: 600; + color: var(--text); + opacity: .85; +} + +.navContent, nav { + display: flex; + gap: 16px; + justify-content: center; } -.navContent { +.navContent a, nav a { + color: var(--text); + opacity: .8; + text-decoration: none; + padding: 6px 8px; + border-radius: 8px; + transition: background .2s ease, opacity .2s ease; +} + +.navContent a:hover, nav a:hover{ background: rgba(0,0,0,.04); opacity: 1; } + +.controls { display: flex; - gap: 1.5rem; - font-weight: 500; - color: #333; + align-items: center; + gap: 8px; +} + +.content { + grid-column: 1 / -1; + padding-top: 8px; } \ No newline at end of file diff --git a/src/components/layout/Header/Header.stories.tsx b/src/components/layout/Header/Header.stories.tsx index ddde81835..78bd5e92d 100644 --- a/src/components/layout/Header/Header.stories.tsx +++ b/src/components/layout/Header/Header.stories.tsx @@ -1,6 +1,9 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import Header from './Header'; +import { ThemeProvider } from '../../../shared/providers/ThemeProvider/ThemeProvider'; +import LocalizationProvider from '../../../shared/providers/LocalizationProvider/LocalizationProvider'; +import '../../../shared/providers/ThemeProvider/theme.css'; const meta: Meta = { title: 'Components/Layout/Header', @@ -14,6 +17,15 @@ const meta: Meta = { }, }, }, + decorators: [ + (Story) => ( + + + + + + ), + ], argTypes: { children: { control: 'text', @@ -40,7 +52,7 @@ export const Playground: Story = { args: { className: '', children: ``, }, diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx index 758c72ebf..acca2f2b3 100644 --- a/src/components/layout/Header/Header.tsx +++ b/src/components/layout/Header/Header.tsx @@ -1,20 +1,32 @@ -import React from 'react'; +import React, { memo } from 'react'; import Logo from '../../ui/Logo/Logo'; -import { HeaderProps } from '../../types'; +import { HeaderProps, Sizes } from '../../types'; import s from './Header.module.css'; +import ThemeToggle from '../../ui/ThemeToggle/ThemeToggle'; +import LanguageToggle from '../../ui/LanguageToggle/LanguageToggle'; +import { useTranslation } from 'react-i18next'; const Header: React.FC = ({ children, className }) => { + const { t } = useTranslation(); return (
- + +
+ {children || } +
+ +
- {children &&
{children}
}
); }; -export default Header; \ No newline at end of file +export default memo(Header); \ No newline at end of file diff --git a/src/components/layout/Layout/Layout.stories.tsx b/src/components/layout/Layout/Layout.stories.tsx index 5a5c4876f..80234200c 100644 --- a/src/components/layout/Layout/Layout.stories.tsx +++ b/src/components/layout/Layout/Layout.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; import Layout from './Layout'; import type { Meta, StoryObj } from '@storybook/react'; +import { useTranslation } from 'react-i18next'; const meta: Meta = { title: 'Components/Layout/Layout', @@ -20,31 +21,34 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const defaultChildren = ( -
-

Добро пожаловать в интерфейс OTUS

-

Этот макет включает прилипший Header с логотипом и областью контента ниже.

-

Вы можете использовать этот Layout для страниц дашбордов, профиля, каталога и др.

-
-); +const DefaultChildren = () => { + const { t } = useTranslation(); + return ( +
+

{t('layout.contentTitle')}

+

{t('layout.contentText')}

+
+ ); +}; const longChildren = (
-

Прокрутка и фиксированный Header

-

Ниже находятся секции, каждая с уникальным содержимым.

+

Прокрутка и фиксированный Header

+

Ниже находятся секции, каждая с уникальным содержимым.

{Array.from({ length: 5 }, (_, i) => (
-

Секция {i + 1}

-

+

Секция {i + 1}

+

Это демонстрация длинного контента. Прокрутите вниз и убедитесь, что Header остаётся на месте.

@@ -54,7 +58,7 @@ const longChildren = ( export const Default: Story = { args: { - children: defaultChildren, + children: , }, }; diff --git a/src/components/layout/Layout/Layout.tsx b/src/components/layout/Layout/Layout.tsx index c74179b65..ecaaee050 100644 --- a/src/components/layout/Layout/Layout.tsx +++ b/src/components/layout/Layout/Layout.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { LayoutProps } from '../../types'; import { Header } from '../Header'; import s from './Layout.module.css'; @@ -14,4 +14,4 @@ const Layout: React.FC = ({ children, className }) => { ); }; -export default Layout; \ No newline at end of file +export default memo(Layout); \ No newline at end of file diff --git a/src/components/ui/LanguageToggle/LanguageToggle.module.css b/src/components/ui/LanguageToggle/LanguageToggle.module.css new file mode 100644 index 000000000..1c700f962 --- /dev/null +++ b/src/components/ui/LanguageToggle/LanguageToggle.module.css @@ -0,0 +1,9 @@ +.toggle{ + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; +} +.toggle:hover{border-color: var(--accent);} diff --git a/src/components/ui/LanguageToggle/LanguageToggle.stories.tsx b/src/components/ui/LanguageToggle/LanguageToggle.stories.tsx new file mode 100644 index 000000000..977db4239 --- /dev/null +++ b/src/components/ui/LanguageToggle/LanguageToggle.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import LanguageToggle from './LanguageToggle'; +import LocalizationProvider from '../../../shared/providers/LocalizationProvider/LocalizationProvider'; +import '../../../shared/providers/ThemeProvider/theme.css'; + +const meta: Meta = { + title: 'Components/UI/LanguageToggle', + component: LanguageToggle, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/src/components/ui/LanguageToggle/LanguageToggle.tsx b/src/components/ui/LanguageToggle/LanguageToggle.tsx new file mode 100644 index 000000000..5813239df --- /dev/null +++ b/src/components/ui/LanguageToggle/LanguageToggle.tsx @@ -0,0 +1,21 @@ +import React, { memo } from 'react'; +import styles from './LanguageToggle.module.css'; +import { useTranslation } from 'react-i18next'; + +const LanguageToggle: React.FC = () => { + const { i18n } = useTranslation(); + const next = i18n.language === 'ru' ? 'en' : 'ru'; + return ( + + ); +}; + +export default memo(LanguageToggle); \ No newline at end of file diff --git a/src/components/ui/LanguageToggle/index.ts b/src/components/ui/LanguageToggle/index.ts new file mode 100644 index 000000000..63dab6284 --- /dev/null +++ b/src/components/ui/LanguageToggle/index.ts @@ -0,0 +1 @@ +export { default as LanguageToggle } from './LanguageToggle'; \ No newline at end of file diff --git a/src/components/ui/Logo/Logo.module.css b/src/components/ui/Logo/Logo.module.css index 0fb69c6a4..19dc6bf8d 100644 --- a/src/components/ui/Logo/Logo.module.css +++ b/src/components/ui/Logo/Logo.module.css @@ -20,7 +20,7 @@ .logoText { font-weight: 600; font-size: 1.5rem; - color: #333; + color: var(--text); } /* Размеры */ diff --git a/src/components/ui/Logo/Logo.tsx b/src/components/ui/Logo/Logo.tsx index 5ac8a4edd..231766367 100644 --- a/src/components/ui/Logo/Logo.tsx +++ b/src/components/ui/Logo/Logo.tsx @@ -1,12 +1,14 @@ import React, { FC } from 'react'; import s from './Logo.module.css'; import { LogoProps, Sizes } from '../../types'; +import { useTranslation } from 'react-i18next'; const Logo: React.FC = ({ size = Sizes.medium }) => { + const { t } = useTranslation(); return (
- MyBrand + {t('header.title')}
); }; diff --git a/src/components/ui/ModalStateDemo/ModalStateDemo.module.css b/src/components/ui/ModalStateDemo/ModalStateDemo.module.css new file mode 100644 index 000000000..0d5e31df9 --- /dev/null +++ b/src/components/ui/ModalStateDemo/ModalStateDemo.module.css @@ -0,0 +1,23 @@ +.wrapper { + display:flex; + gap:12px; + align-items:center +} +.input { + border:1px solid var(--border); + background:var(--panel); + color:var(--text); + padding:8px 10px; + border-radius:8px; + min-width:260px +} +.button{ + border:1px solid var(--border); + background:var(--panel); + padding:8px 12px; + border-radius:8px; + cursor:pointer +} +.button:hover{ + border-color:var(--accent) +} diff --git a/src/components/ui/ModalStateDemo/ModalStateDemo.stories.tsx b/src/components/ui/ModalStateDemo/ModalStateDemo.stories.tsx new file mode 100644 index 000000000..268ff5c31 --- /dev/null +++ b/src/components/ui/ModalStateDemo/ModalStateDemo.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import ModalStateDemo from './ModalStateDemo'; +import { ThemeProvider } from '../../../shared/providers/ThemeProvider/ThemeProvider'; +import '../../../shared/providers/ThemeProvider/theme.css'; + +const meta: Meta = { + title: 'Components/UI/ModalStateDemo', + component: ModalStateDemo, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; \ No newline at end of file diff --git a/src/components/ui/ModalStateDemo/ModalStateDemo.tsx b/src/components/ui/ModalStateDemo/ModalStateDemo.tsx new file mode 100644 index 000000000..cc1d73678 --- /dev/null +++ b/src/components/ui/ModalStateDemo/ModalStateDemo.tsx @@ -0,0 +1,31 @@ +import React, { useState, useRef, useEffect, memo } from 'react'; +import Modal from '../Modal/Modal'; +import styles from './ModalStateDemo.module.css'; + +const ModalStateDemo: React.FC = () => { + const [open, setOpen] = useState(false); + const [text, setText] = useState(''); + const inputRef = useRef(null); + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + return ( +
+ setText(e.target.value)} + ref={inputRef} + /> + + + setOpen(false)}> +

Ваш текст:

+

{text || '—'}

+
+
+ ); +}; + +export default memo(ModalStateDemo); \ No newline at end of file diff --git a/src/components/ui/ModalStateDemo/index.ts b/src/components/ui/ModalStateDemo/index.ts new file mode 100644 index 000000000..625c65b28 --- /dev/null +++ b/src/components/ui/ModalStateDemo/index.ts @@ -0,0 +1 @@ +export { default as ModalStateDemo } from './ModalStateDemo'; \ No newline at end of file diff --git a/src/components/ui/ThemeToggle/ThemeToggle.module.css b/src/components/ui/ThemeToggle/ThemeToggle.module.css new file mode 100644 index 000000000..dd92ab2c5 --- /dev/null +++ b/src/components/ui/ThemeToggle/ThemeToggle.module.css @@ -0,0 +1,10 @@ +.toggle{ + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 10px; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0,0,0,.06); +} +.toggle:hover{border-color: var(--accent);} diff --git a/src/components/ui/ThemeToggle/ThemeToggle.stories.tsx b/src/components/ui/ThemeToggle/ThemeToggle.stories.tsx new file mode 100644 index 000000000..365fe8edc --- /dev/null +++ b/src/components/ui/ThemeToggle/ThemeToggle.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import ThemeToggle from './ThemeToggle'; +import { ThemeProvider } from '../../../shared/providers/ThemeProvider/ThemeProvider'; +import '../../../shared/providers/ThemeProvider/theme.css'; + +const meta: Meta = { + title: 'Components/UI/ThemeToggle', + component: ThemeToggle, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; \ No newline at end of file diff --git a/src/components/ui/ThemeToggle/ThemeToggle.tsx b/src/components/ui/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 000000000..774faa0d4 --- /dev/null +++ b/src/components/ui/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,20 @@ +import React, { memo } from 'react'; +import styles from './ThemeToggle.module.css'; +import { useTheme } from '../../../shared/providers/ThemeProvider/ThemeProvider'; + +const ThemeToggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + return ( + + ); +}; + +export default memo(ThemeToggle); diff --git a/src/components/ui/ThemeToggle/index.ts b/src/components/ui/ThemeToggle/index.ts new file mode 100644 index 000000000..75d310bd4 --- /dev/null +++ b/src/components/ui/ThemeToggle/index.ts @@ -0,0 +1 @@ +export { default as ThemeToggle } from './ThemeToggle'; \ No newline at end of file diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index a66162964..9185f821c 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,2 +1,4 @@ export { Logo } from './Logo'; -export { Modal } from './Modal'; \ No newline at end of file +export { Modal } from './Modal'; +export { ThemeToggle } from './ThemeToggle'; +export { LanguageToggle } from './LanguageToggle'; \ No newline at end of file diff --git a/src/shared/providers/LocalizationProvider/LocalizationProvider.tsx b/src/shared/providers/LocalizationProvider/LocalizationProvider.tsx new file mode 100644 index 000000000..705e8fd6a --- /dev/null +++ b/src/shared/providers/LocalizationProvider/LocalizationProvider.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import i18n from './i18n'; +import { I18nextProvider } from 'react-i18next'; + +const LocalizationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return {children}; +}; + +export default LocalizationProvider; diff --git a/src/shared/providers/LocalizationProvider/i18n.ts b/src/shared/providers/LocalizationProvider/i18n.ts new file mode 100644 index 000000000..96b24f6b9 --- /dev/null +++ b/src/shared/providers/LocalizationProvider/i18n.ts @@ -0,0 +1,32 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +const resources = { + ru: { translation: { + hello: 'Привет', + theme: 'Тема', + language: 'Язык', + openModal: 'Открыть модалку', + nav: { home: 'Главная', about: 'О нас', contacts: 'Контакты' }, + header: { title: 'Мой проект', dashboard: 'Дашборд' }, + layout: { contentTitle: 'Добро пожаловать', contentText: 'Это основной контент страницы.' }, + }}, + en: { translation: { + hello: 'Hello', + theme: 'Theme', + language: 'Language', + openModal: 'Open modal', + nav: { home: 'Home', about: 'About', contacts: 'Contacts' }, + header: { title: 'My Project', dashboard: 'Dashboard' }, + layout: { contentTitle: 'Welcome', contentText: 'This is the main content area.' }, + }}, +}; + +i18n.use(initReactI18next).init({ + resources, + lng: 'ru', + fallbackLng: 'en', + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/src/shared/providers/LocalizationProvider/index.ts b/src/shared/providers/LocalizationProvider/index.ts new file mode 100644 index 000000000..b7263bb80 --- /dev/null +++ b/src/shared/providers/LocalizationProvider/index.ts @@ -0,0 +1 @@ +export { default as LocalizationProvider } from './LocalizationProvider'; \ No newline at end of file diff --git a/src/shared/providers/ThemeProvider/ThemeProvider.tsx b/src/shared/providers/ThemeProvider/ThemeProvider.tsx new file mode 100644 index 000000000..fbcb42fc7 --- /dev/null +++ b/src/shared/providers/ThemeProvider/ThemeProvider.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; + +export type Theme = 'light' | 'dark'; + +type ThemeContextValue = { + theme: Theme; + setTheme: (t: Theme) => void; + toggleTheme: () => void; +}; + +const ThemeContext = createContext(undefined); + +const STORAGE_KEY = 'app.theme'; + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [theme, setThemeState] = useState(() => { + const saved = (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? undefined; + if (saved === 'light' || saved === 'dark') return saved; + // system preference fallback + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light'; + }); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, theme); + document.documentElement.dataset.theme = theme; // hook for CSS variables + }, [theme]); + + const setTheme = (t: Theme) => setThemeState(t); + const toggleTheme = () => setThemeState((p) => (p === 'light' ? 'dark' : 'light')); + + const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme]); + return {children}; +}; + +export const useTheme = () => { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +}; diff --git a/src/shared/providers/ThemeProvider/theme.css b/src/shared/providers/ThemeProvider/theme.css new file mode 100644 index 000000000..ac6c102e2 --- /dev/null +++ b/src/shared/providers/ThemeProvider/theme.css @@ -0,0 +1,17 @@ +:root{ + --bg: #f6f8fa; + --text: #0f172a; + --panel: #ffffff; + --border: #e5e7eb; + --accent: #2563eb; +} + +:root[data-theme='dark']{ + --bg: #0b1020; + --text: #e5e7eb; + --panel: #0f172a; + --border: #1f2937; + --accent: #60a5fa; +} + +body{background:var(--bg); color:var(--text);border-color: var(--border);} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1fd6f7887..3b542d942 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "downlevelIteration": true, "moduleResolution": "node", "resolveJsonModule": true, - "jsx": "react" + "jsx": "react-jsx" } } \ No newline at end of file From 5d4b4fc8041cd948e2071d2786ecee5d3633ddf2 Mon Sep 17 00:00:00 2001 From: Ivan Komrakov Date: Tue, 19 Aug 2025 05:01:37 +0300 Subject: [PATCH 6/9] init: new hw lists keys events portals feat(ui/modal): implement portal mount to body with ESC close and backdrop click feat(lib): add random data generators for Product and Operation feat(ecommerce/product-list): render products array via ProductBrief feat(ecommerce/product-list): append items by "show more" button feat(ecommerce/product-list): auto-load next page via IntersectionObserver feat(finance/operation): add Operation and OperationFull components feat(finance/operation-list): implement infinite list with IO and "show more" feat(storybook): add ProductList playground with generatedCount and pageSize controls feat(storybook): add OperationList playground with generatedCount and pageSize controls --- .../AddToCartButton.module.css | 2 +- .../ProductList/ProductList.module.css | 22 ++++++ .../ProductList/ProductList.stories.tsx | 34 ++++++++++ .../ecommerce/ProductList/ProductList.tsx | 68 +++++++++++++++++++ src/components/ecommerce/ProductList/index.ts | 1 + .../Operations/Operation/Operation.module.css | 21 ++++++ .../Operation/Operation.stories.tsx | 27 ++++++++ .../Operations/Operation/Operation.tsx | 27 ++++++++ .../finance/Operations/Operation/index.ts | 1 + .../OperationFull/OperationFull.module.css | 15 ++++ .../OperationFull/OperationFull.stories.tsx | 27 ++++++++ .../OperationFull/OperationFull.tsx | 56 +++++++++++++++ .../finance/Operations/OperationFull/index.ts | 1 + .../OperationList/OperationList.module.css | 8 +++ .../OperationList/OperationList.stories.tsx | 33 +++++++++ .../OperationList/OperationList.tsx | 68 +++++++++++++++++++ .../finance/Operations/OperationList/index.ts | 2 + src/components/finance/Operations/index.ts | 3 + src/components/finance/index.ts | 5 +- src/components/index.ts | 5 ++ src/components/types.ts | 32 ++++++++- src/components/ui/Modal/Modal.stories.tsx | 10 +-- src/components/ui/Modal/Modal.tsx | 43 ++++++++++-- src/lib/generators.ts | 52 ++++++++++++++ 24 files changed, 551 insertions(+), 12 deletions(-) create mode 100644 src/components/ecommerce/ProductList/ProductList.module.css create mode 100644 src/components/ecommerce/ProductList/ProductList.stories.tsx create mode 100644 src/components/ecommerce/ProductList/ProductList.tsx create mode 100644 src/components/ecommerce/ProductList/index.ts create mode 100644 src/components/finance/Operations/Operation/Operation.module.css create mode 100644 src/components/finance/Operations/Operation/Operation.stories.tsx create mode 100644 src/components/finance/Operations/Operation/Operation.tsx create mode 100644 src/components/finance/Operations/Operation/index.ts create mode 100644 src/components/finance/Operations/OperationFull/OperationFull.module.css create mode 100644 src/components/finance/Operations/OperationFull/OperationFull.stories.tsx create mode 100644 src/components/finance/Operations/OperationFull/OperationFull.tsx create mode 100644 src/components/finance/Operations/OperationFull/index.ts create mode 100644 src/components/finance/Operations/OperationList/OperationList.module.css create mode 100644 src/components/finance/Operations/OperationList/OperationList.stories.tsx create mode 100644 src/components/finance/Operations/OperationList/OperationList.tsx create mode 100644 src/components/finance/Operations/OperationList/index.ts create mode 100644 src/components/finance/Operations/index.ts create mode 100644 src/lib/generators.ts diff --git a/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css b/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css index b7a558c77..06c6074c0 100644 --- a/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css +++ b/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css @@ -4,7 +4,7 @@ color: white; border: none; border-radius: 8px; - padding: 12px 24px; + padding: 12px 16px; font-size: 14px; font-weight: 600; cursor: pointer; diff --git a/src/components/ecommerce/ProductList/ProductList.module.css b/src/components/ecommerce/ProductList/ProductList.module.css new file mode 100644 index 000000000..f85dab263 --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductList.module.css @@ -0,0 +1,22 @@ + +.wrap { width: 100%; } +.grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; +} +.item { display: contents; } +.footer { display: flex; justify-content: center; padding: 16px 0 24px; } +.moreBtn { + border: none; + padding: 10px 16px; + border-radius: 10px; + background: var(--btn-bg, #111); + color: var(--btn-fg, #fff); + cursor: pointer; +} +.moreBtn:hover { opacity: .9; } +.sentinel { height: 1px; } diff --git a/src/components/ecommerce/ProductList/ProductList.stories.tsx b/src/components/ecommerce/ProductList/ProductList.stories.tsx new file mode 100644 index 000000000..6b4c986e8 --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductList.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ProductList } from '.'; +import { createRandomProduct } from '../../../lib/generators'; + +const meta: Meta = { + title: 'Components/E-commerce/ProductList', + component: ProductList, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + argTypes: { + items: { control: { type: 'number', min: 0, max: 1000, step: 4 } }, + pageSize: { control: { type: 'number', min: 1, max: 60, step: 1 } }, + useInfinite: { control: 'boolean' }, + unlimited: { control: 'boolean' }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + items: 16, + pageSize: 8, + useInfinite: true, + unlimited: false, + } as any, + render: (args) => { + const items = Array.from({ length: (args as any).generatedCount }, () => createRandomProduct()); + return ; + }, +}; diff --git a/src/components/ecommerce/ProductList/ProductList.tsx b/src/components/ecommerce/ProductList/ProductList.tsx new file mode 100644 index 000000000..5d90c86c1 --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductList.tsx @@ -0,0 +1,68 @@ + +import React, { useEffect, useMemo, useRef, useState, memo, useCallback } from 'react'; +import styles from './ProductList.module.css'; +import { Product, ProductListProps } from '../../types'; +import { ProductBrief } from '../ProductBrief'; +import { createRandomProduct } from '../../../lib/generators'; + +const DEFAULT_PAGE = 12; +const CAP = 200; + +const ProductList: React.FC = ({ items = [], pageSize = DEFAULT_PAGE, useInfinite = true, unlimited = false }) => { + const [data, setData] = useState(() => items.length ? items : Array.from({ length: pageSize }, () => createRandomProduct())); + const [hasMore, setHasMore] = useState(true); + const sentinelRef = useRef(null); + const ioRef = useRef(null); + const loadingRef = useRef(false); + + const append = useCallback((count: number) => { + if (loadingRef.current) return; + loadingRef.current = true; + const next = Array.from({ length: count }, () => createRandomProduct()); + setData(prev => { + const merged = [...prev, ...next]; + if (!unlimited && merged.length >= CAP) setHasMore(false); + return merged; + }); + setTimeout(() => { loadingRef.current = false; }, 0); + }, [unlimited]); + + useEffect(() => { + if (!useInfinite || !hasMore) return; + const target = sentinelRef.current; + if (!target) return; + ioRef.current?.disconnect(); + ioRef.current = new IntersectionObserver((entries) => { + const first = entries[0]; + if (first?.isIntersecting && hasMore) { + append(pageSize); + } + }, { root: null, rootMargin: '200px', threshold: 0 }); + ioRef.current.observe(target); + return () => ioRef.current?.disconnect(); + }, [useInfinite, pageSize, append, hasMore]); + + return ( +
+
    + {data.map((p, idx) => ( +
  • + +
  • + ))} +
+ {hasMore && ( + <> +
+
+ +
+ + )} +
+ ); +}; + +export default memo(ProductList); diff --git a/src/components/ecommerce/ProductList/index.ts b/src/components/ecommerce/ProductList/index.ts new file mode 100644 index 000000000..71734a82a --- /dev/null +++ b/src/components/ecommerce/ProductList/index.ts @@ -0,0 +1 @@ +export { default as ProductList } from './ProductList'; diff --git a/src/components/finance/Operations/Operation/Operation.module.css b/src/components/finance/Operations/Operation/Operation.module.css new file mode 100644 index 000000000..4f7c1c414 --- /dev/null +++ b/src/components/finance/Operations/Operation/Operation.module.css @@ -0,0 +1,21 @@ + +.card { + background: #ffffff; + border-radius: 12px; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + border: 1px solid #f0f0f0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + transition: all 0.2s ease; + display: grid; + gap: 10px; +} +.card:hover { box-shadow: 0 4px 12rem rgba(0, 0, 0, 0.1); transform: translateY(-2px); } +.header { display: flex; align-items: center; justify-content: space-between; } +.title { font-size: 18px; font-weight: 700; margin: 0; color: #2c3e50; } +.amount { font-weight: 800; font-size: 18px; line-height: 1; font-variant-numeric: tabular-nums; } +.income { color: #2e7d32; } .expense { color: #f52c16; } +.meta { display: flex; gap: 12px; font-size: 14px; color: #7f8c8d; font-weight: 500; } +.category { display: inline-block; background: linear-gradient(135deg, #4b6cb7, #182848); color: #fff; padding: 4px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; width: fit-content; } +.desc { margin-top: 2px; color: #34495e; font-size: 14px; line-height: 1.35; } +@media (max-width: 768px) { .title { font-size: 16px; } .amount { font-size: 16px; } } diff --git a/src/components/finance/Operations/Operation/Operation.stories.tsx b/src/components/finance/Operations/Operation/Operation.stories.tsx new file mode 100644 index 000000000..55b4008be --- /dev/null +++ b/src/components/finance/Operations/Operation/Operation.stories.tsx @@ -0,0 +1,27 @@ + +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Operation } from '.'; +import type { Operation as OperationType } from '../../../types'; + +const meta: Meta = { + title: 'Components/Finance/Operations/Operation', + component: Operation, + tags: ['autodocs'], + argTypes: {}, +}; +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + operation: { + id: 'op-1', + name: 'Оплата подписки', + amount: -499, + category: 'Подписка', + description: 'Списание за месяц сервиса', + date: new Date().toISOString(), + } as OperationType, + }, +}; diff --git a/src/components/finance/Operations/Operation/Operation.tsx b/src/components/finance/Operations/Operation/Operation.tsx new file mode 100644 index 000000000..83c342e18 --- /dev/null +++ b/src/components/finance/Operations/Operation/Operation.tsx @@ -0,0 +1,27 @@ + +import React from 'react'; +import s from './Operation.module.css'; +import { OperationItemProps } from '../../../types'; + +const Operation: React.FC = ({ operation }) => { + const isIncome = operation.amount >= 0; + const sign = isIncome ? '+' : '-'; + const amountCls = [s.amount, isIncome ? s.income : s.expense].join(' '); + const formattedAmount = `${sign}${Math.abs(operation.amount).toLocaleString('ru-RU')} ₽`; + const dt = new Date(operation.date).toLocaleDateString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' }); + + return ( +
+
+

{operation.name}

+
{formattedAmount}
+
+
+ {operation.category} + +
+ {operation.description &&

{operation.description}

} +
+ ); +}; +export default Operation; diff --git a/src/components/finance/Operations/Operation/index.ts b/src/components/finance/Operations/Operation/index.ts new file mode 100644 index 000000000..135fc2861 --- /dev/null +++ b/src/components/finance/Operations/Operation/index.ts @@ -0,0 +1 @@ +export { default as Operation } from './Operation'; diff --git a/src/components/finance/Operations/OperationFull/OperationFull.module.css b/src/components/finance/Operations/OperationFull/OperationFull.module.css new file mode 100644 index 000000000..65dd6d02f --- /dev/null +++ b/src/components/finance/Operations/OperationFull/OperationFull.module.css @@ -0,0 +1,15 @@ + +.cardFull { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,.05); border: 1px solid #f0f0f0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; transition: all .2s ease; } +.cardFull:hover { box-shadow: 0 4px 12rem rgba(0,0,0,.1); transform: translateY(-2px); } +.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.titleGroup { display: grid; gap: 6px; } +.title { font-size: 20px; font-weight: 800; margin: 0; color: #2c3e50; } +.meta { display: flex; align-items: center; gap: 12px; color: #7f8c8d; font-size: 14px; font-weight: 500; } +.category { display: inline-block; background: linear-gradient(135deg, #4b6cb7, #182848); color: #fff; padding: 4px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; width: fit-content; } +.amount { font-weight: 800; font-size: 20px; font-variant-numeric: tabular-nums; } +.income { color: #2e7d32; } .expense { color: #f52c16; } +.row { display: grid; grid-template-columns: 160px 1fr; gap: 10px; align-items: start; padding: 8px 0; border-top: 1px dashed #e9ecef; } +.label { color: #7f8c8d; font-weight: 600; } +.value { font-weight: 500; color: #2c3e50; } +.textarea { width: 100%; min-height: 100px; border-radius: 8px; border: 1px solid #dee2e6; padding: 10px; background: #f8f9fa; } +@media (max-width: 768px) { .title { font-size: 18px; } .amount { font-size: 18px; } .row { grid-template-columns: 120px 1fr; } } diff --git a/src/components/finance/Operations/OperationFull/OperationFull.stories.tsx b/src/components/finance/Operations/OperationFull/OperationFull.stories.tsx new file mode 100644 index 000000000..c89abf31c --- /dev/null +++ b/src/components/finance/Operations/OperationFull/OperationFull.stories.tsx @@ -0,0 +1,27 @@ + +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { OperationFull } from '.'; +import type { Operation as OperationType } from '../../../types'; + +const meta: Meta = { + title: 'Components/Finance/Operations/OperationFull', + component: OperationFull, + tags: ['autodocs'], + argTypes: {}, +}; +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + operation: { + id: 'op-2', + name: 'Зачисление средств', + amount: 27500, + category: 'Поступление', + description: 'Возврат средств по заказу', + date: new Date().toISOString(), + } as OperationType, + }, +}; diff --git a/src/components/finance/Operations/OperationFull/OperationFull.tsx b/src/components/finance/Operations/OperationFull/OperationFull.tsx new file mode 100644 index 000000000..6c4d63416 --- /dev/null +++ b/src/components/finance/Operations/OperationFull/OperationFull.tsx @@ -0,0 +1,56 @@ + +import React from 'react'; +import s from './OperationFull.module.css'; +import { OperationFullProps } from '../../../types'; + +const OperationFull: React.FC = ({ operation }) => { + const isIncome = operation.amount >= 0; + const sign = isIncome ? '+' : '-'; + const amountCls = [s.amount, isIncome ? s.income : s.expense].join(' '); + const formattedAmount = `${sign}${Math.abs(operation.amount).toLocaleString('ru-RU')} ₽`; + const formattedDate = new Date(operation.date).toLocaleString('ru-RU', { + day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); + + return ( +
+
+
+

{operation.name}

+
+ {operation.category} + +
+
+
{formattedAmount}
+
+ +
+
Название
+
{operation.name}
+
+
+
Сумма
+
{formattedAmount}
+
+
+
Категория
+
{operation.category}
+
+
+
Дата
+
+ +
+
+
+
Описание
+
+