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/.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 5b1dfa392..02e21fd85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,14 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@hookform/resolvers": "^5.2.1", "clsx": "^1.2.1", + "i18next": "^25.3.2", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.62.0", + "react-i18next": "^15.6.1", + "yup": "^1.7.0" }, "devDependencies": { "@babel/core": "^7.22.1", @@ -46,6 +51,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", @@ -2032,13 +2038,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" } @@ -2620,6 +2623,18 @@ "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==", "dev": true }, + "node_modules/@hookform/resolvers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz", + "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.10.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz", @@ -4341,6 +4356,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@storybook/addon-actions": { "version": "7.6.17", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.17.tgz", @@ -11021,6 +11042,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 +12539,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 +13203,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", @@ -13546,6 +13635,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", @@ -13726,6 +13824,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", @@ -19614,6 +19743,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -19941,6 +20076,48 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-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", @@ -20197,12 +20374,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", @@ -21335,6 +21506,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", @@ -21880,6 +22064,12 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -21928,6 +22118,12 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -21964,6 +22160,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", @@ -22153,7 +22362,6 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, "engines": { "node": ">=12.20" }, @@ -22198,7 +22406,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" @@ -22703,6 +22911,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", @@ -23542,6 +23759,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", + "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -24908,13 +25137,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", @@ -25246,6 +25471,14 @@ "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==", "dev": true }, + "@hookform/resolvers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz", + "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==", + "requires": { + "@standard-schema/utils": "^0.3.0" + } + }, "@humanwhocodes/config-array": { "version": "0.10.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz", @@ -26384,6 +26617,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "@storybook/addon-actions": { "version": "7.6.17", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.17.tgz", @@ -31513,6 +31751,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 +32889,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 +33366,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", @@ -33415,6 +33699,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", @@ -33536,6 +33828,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", @@ -37730,6 +38030,11 @@ } } }, + "property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -37986,6 +38291,21 @@ } } }, + "react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "requires": {} + }, + "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", @@ -38164,12 +38484,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", @@ -39034,6 +39348,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", @@ -39444,6 +39767,11 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -39483,6 +39811,11 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -39512,6 +39845,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", @@ -39642,8 +39984,7 @@ "type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" }, "type-is": { "version": "1.6.18", @@ -39676,7 +40017,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", @@ -40036,6 +40377,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", @@ -40641,6 +40987,17 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true }, + "yup": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", + "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "requires": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 492664d1f..0b751f5d1 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-storybook", + "deploy": "npx gh-pages -d storybook-static", "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", @@ -64,8 +68,13 @@ "webpack-dev-server": "^4.15.0" }, "dependencies": { + "@hookform/resolvers": "^5.2.1", "clsx": "^1.2.1", + "i18next": "^25.3.2", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.62.0", + "react-i18next": "^15.6.1", + "yup": "^1.7.0" } } diff --git a/src/app/App.css b/src/app/App.module.css similarity index 89% rename from src/app/App.css rename to src/app/App.module.css index 78b8850cf..d0d0b02eb 100644 --- a/src/app/App.css +++ b/src/app/App.module.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..d110eb711 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,16 +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 -

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

-
+
+ + +
+
+
+ logo +
+
+
+
); } diff --git a/src/components/complex/ComponentInfo/ComponentInfo.module.css b/src/components/complex/ComponentInfo/ComponentInfo.module.css new file mode 100644 index 000000000..782591606 --- /dev/null +++ b/src/components/complex/ComponentInfo/ComponentInfo.module.css @@ -0,0 +1,23 @@ + +.root { + font-family: Montserrat, sans-serif; + margin: 1rem; + background: var(--bg); +} +.title { + font-size: 28px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text); +} +.main { + display: inline-block; + margin-top: 16px; + border-radius: 8px; + padding: 16px; + border: 1px solid #f0f0f0; + box-shadow: 1px 3px 20px 0 rgba(110, 110, 110, 0.3); + background: var(--panel); +} +.main:empty { display: none; } +.fullWidth { display: block; } diff --git a/src/components/complex/ComponentInfo/ComponentInfo.stories.tsx b/src/components/complex/ComponentInfo/ComponentInfo.stories.tsx new file mode 100644 index 000000000..dcceab78e --- /dev/null +++ b/src/components/complex/ComponentInfo/ComponentInfo.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ComponentInfo } from '.'; + +const meta: Meta = { + title: 'Components/Complex/ComponentInfo', + component: ComponentInfo, + tags: ['autodocs'], + argTypes: { + fullWidth: { control: 'boolean' }, + title: { control: 'text' }, + desc: { control: 'text' }, + }, + args: { + title: 'Компонент', + desc: 'Описание компонента, ограничения и особенности.', + fullWidth: false, + }, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + +
+ Тут будет пример использования +
+
+ ), +}; + +export const FullWidth: Story = { + args: { fullWidth: true, title: 'Компонент на всю ширину' }, + render: (args) => ( + +
+ Демонстрация fullWidth +
+
+ ), +}; diff --git a/src/components/complex/ComponentInfo/ComponentInfo.tsx b/src/components/complex/ComponentInfo/ComponentInfo.tsx new file mode 100644 index 000000000..36a0b6ffe --- /dev/null +++ b/src/components/complex/ComponentInfo/ComponentInfo.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import clsx from 'clsx'; +import s from './ComponentInfo.module.css'; + +export type ComponentInfoProps = React.HTMLAttributes & { + className?: string; + title?: React.ReactNode; + desc?: React.ReactNode; + children?: React.ReactNode; + fullWidth?: boolean; +}; + +const ComponentInfo: React.FC = ({ className, desc, fullWidth, title, children, ...props }) => { + return ( +
+ {title &&
{title}
} + {desc &&
{desc}
} +
{children}
+
+ ); +}; + +export default ComponentInfo; diff --git a/src/components/complex/ComponentInfo/index.ts b/src/components/complex/ComponentInfo/index.ts new file mode 100644 index 000000000..46539e9db --- /dev/null +++ b/src/components/complex/ComponentInfo/index.ts @@ -0,0 +1,2 @@ +export { default as ComponentInfo } from './ComponentInfo'; +export type { ComponentInfoProps } from './ComponentInfo'; diff --git a/src/components/complex/CroppedText/CroppedText.module.css b/src/components/complex/CroppedText/CroppedText.module.css new file mode 100644 index 000000000..c3d73aa36 --- /dev/null +++ b/src/components/complex/CroppedText/CroppedText.module.css @@ -0,0 +1,5 @@ + +.root { + overflow: hidden; + word-wrap: break-word; +} diff --git a/src/components/complex/CroppedText/CroppedText.stories.tsx b/src/components/complex/CroppedText/CroppedText.stories.tsx new file mode 100644 index 000000000..3ae3d4336 --- /dev/null +++ b/src/components/complex/CroppedText/CroppedText.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; +import { CroppedText } from '.'; +import { ComponentInfo } from '../ComponentInfo'; + +const meta: Meta = { + title: 'Components/Complex/CroppedText', + component: CroppedText, + tags: ['autodocs'], + args: { + opened: false, + rows: 3, + ellipsis: '…', + children: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. +Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`, + }, + argTypes: { + opened: { control: 'boolean' }, + rows: { control: { type: 'number', min: 0, max: 12, step: 1 } }, + ellipsis: { control: 'text' }, + }, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + +
+ +
+
+ ), +}; + +export const ResponsiveWidth: Story = { + render: (args) => { + const [w, setW] = useState(520); + return ( + +
+ setW(parseInt(e.target.value, 10))} + /> + width: {w}px +
+
+ +
+
+ ); + }, +}; + +export const Opened: Story = { + args: { opened: true }, + render: (args) => ( + +
+ +
+
+ ), +}; diff --git a/src/components/complex/CroppedText/CroppedText.tsx b/src/components/complex/CroppedText/CroppedText.tsx new file mode 100644 index 000000000..85e5ee4b7 --- /dev/null +++ b/src/components/complex/CroppedText/CroppedText.tsx @@ -0,0 +1,147 @@ +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import s from './CroppedText.module.css'; +import { useEvent } from '../../../shared/hooks/useEvent'; + +export type CroppedTextProps = React.HTMLAttributes & { + className?: string; + /** исходный текст */ + children: string; + /** показывать полный текст */ + opened?: boolean; + /** количество строк в свернутом состоянии */ + rows?: number; + /** строка-эллипсис */ + ellipsis?: string; + style: React.CSSProperties; +}; + +const DEFAULT_ROWS = 3; +const DEFAULT_ELLIPSIS = '…'; + +/** измерение line-height */ +function getLineHeightPx(el: HTMLElement): number { + const cs = window.getComputedStyle(el); + const lh = cs.lineHeight; + if (lh === 'normal') { + const fs = parseFloat(cs.fontSize || '16'); + return fs * 1.2; + } + return parseFloat(lh || '0') || 0; +} + +/** создаёт скрытый измерительный контейнер шириной как у target */ +function createMeasure(root: HTMLElement): HTMLDivElement { + const div = document.createElement('div'); + const cs = window.getComputedStyle(root); + div.style.position = 'absolute'; + div.style.left = '-99999px'; + div.style.top = '-99999px'; + div.style.whiteSpace = 'normal'; + div.style.wordWrap = 'break-word'; + div.style.width = root.clientWidth + 'px'; + // Наследуем базовые текстовые стили + div.style.fontFamily = cs.fontFamily as string; + div.style.fontSize = cs.fontSize as string; + div.style.fontWeight = cs.fontWeight as string; + div.style.letterSpacing = cs.letterSpacing as string; + div.style.lineHeight = cs.lineHeight as string; + div.style.padding = cs.padding as string; + div.style.border = cs.border as string; + div.style.boxSizing = cs.boxSizing as string; + document.body.appendChild(div); + return div; +} + +const CroppedText: React.FC = ({ + className, + children, + opened = false, + rows = DEFAULT_ROWS, + ellipsis = DEFAULT_ELLIPSIS, + style, + ...rest +}) => { + const rootRef = useRef(null); + const [text, setText] = useState(children); + + const recalc = useEvent(() => { + const root = rootRef.current; + if (!root) return; + if (opened) { + setText(children); + return; + } + // Пустые случаи + if (!children || rows <= 0) { + setText(children); + return; + } + // Измеряем доступную высоту + const lineHeight = getLineHeightPx(root) || 20; + const maxH = rows * lineHeight; + + // Создаём измеритель + const probe = createMeasure(root); + let lo = 0, + hi = children.length, + best = 0; + + const fits = (substr: string) => { + probe.textContent = substr; + const h = probe.getBoundingClientRect().height; + return h <= maxH + 0.5; + }; + + // Быстрые граничные проверки + if (fits(children)) { + setText(children); + document.body.removeChild(probe); + return; + } + // Бинарный поиск по длине подстроки + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = children.slice(0, Math.max(0, mid)) + ellipsis; + if (fits(candidate)) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + const finalText = children.slice(0, Math.max(0, best)) + (best < children.length ? ellipsis : ''); + setText(finalText); + document.body.removeChild(probe); + }); + + // Пересчёт: при изменениях текста/строк/режима + useLayoutEffect(() => { + setText(children); + recalc(); + }, [children, rows, opened, recalc]); + + // ResizeObserver: при изменении ширины контейнера + useLayoutEffect(() => { + const el = rootRef.current; + if (!el) return; + let raf = 0; + const ro = new ResizeObserver(() => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(recalc); + }); + ro.observe(el); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + }; + }, [recalc]); + + return ( +
+ {opened ? children : text} +
+ ); +}; + +export default CroppedText; diff --git a/src/components/complex/CroppedText/index.ts b/src/components/complex/CroppedText/index.ts new file mode 100644 index 000000000..595589252 --- /dev/null +++ b/src/components/complex/CroppedText/index.ts @@ -0,0 +1,2 @@ +export { default as CroppedText } from './CroppedText'; +export type { CroppedTextProps } from './CroppedText'; diff --git a/src/components/complex/InfinityList/InfinityList.module.css b/src/components/complex/InfinityList/InfinityList.module.css new file mode 100644 index 000000000..081d922cd --- /dev/null +++ b/src/components/complex/InfinityList/InfinityList.module.css @@ -0,0 +1,11 @@ +.root { + overflow:auto; + background: var(--bg); +} +.holder { position:relative; } +.item { + position:absolute; + left:0; + right:0; + color: var(--text); +} diff --git a/src/components/complex/InfinityList/InfinityList.stories.tsx b/src/components/complex/InfinityList/InfinityList.stories.tsx new file mode 100644 index 000000000..0d518c220 --- /dev/null +++ b/src/components/complex/InfinityList/InfinityList.stories.tsx @@ -0,0 +1,121 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { InfinityList, InfinityListRef } from './InfinityList'; +import { ComponentInfo } from '../ComponentInfo'; + +const DEFAULT_COUNT = 100; +const DEFAULT_TIMEOUT = 200; + +type BoxProps = { data: number }; +const Box: React.FC = ({ data }) => ( +
+ #{data} +
+); + +type DemoProps = { + generatedCount?: number; + height?: number; + itemHeight?: number; + reserve?: number; + overscan?: number; + timeout?: number; +}; +const Demo: React.FC = ({ + generatedCount = DEFAULT_COUNT, + height = 360, + itemHeight = 48, + reserve = 100, + overscan = 2, + timeout = DEFAULT_TIMEOUT, +}) => { + const [items, setItems] = useState(() => + Array(generatedCount) + .fill(0) + .map((_, i) => i + 1) + ); + const ref = useRef(); + useLayoutEffect(() => { + ref.current?.scrollTo(Math.min(50, items.length)); + }, [items.length]); + + return ( + + + innerRef={ref as any} + items={items} + itemElement={Box as any} + itemHeight={itemHeight} + reserve={reserve} + overscan={overscan} + height={height} + startLoading={
Загружаю вверх…
} + endLoading={
Загружаю вниз…
} + onStart={() => + new Promise((resolve) => { + setTimeout(() => { + setItems((v) => { + const min = v[0] ?? 0; + return [ + ...Array(generatedCount) + .fill(0) + .map((_, i) => min - i - 1) + .reverse(), + ...v, + ]; + }); + resolve(); + }, timeout); + }) + } + onEnd={() => + new Promise((resolve) => { + setTimeout(() => { + setItems((v) => { + const max = v[v.length - 1] ?? 0; + return [ + ...v, + ...Array(generatedCount) + .fill(0) + .map((_, i) => max + i + 1), + ]; + }); + resolve(); + }, timeout); + }) + } + style={undefined} + /> +
+ ); +}; + +const meta: Meta = { + title: 'Components/Complex/InfinityList', + component: Demo, + tags: ['autodocs'], + args: { generatedCount: 100, height: 360, itemHeight: 48, reserve: 100, overscan: 2, timeout: 200 }, + argTypes: { + generatedCount: { control: { type: 'number', min: 0, max: 1000, step: 50 } }, + height: { control: { type: 'number', min: 200, max: 800, step: 20 } }, + itemHeight: { control: { type: 'number', min: 24, max: 160, step: 4 } }, + reserve: { control: { type: 'number', min: 0, max: 600, step: 10 } }, + overscan: { control: { type: 'number', min: 0, max: 10, step: 1 } }, + timeout: { control: { type: 'number', min: 0, max: 1000, step: 50 } }, + }, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; diff --git a/src/components/complex/InfinityList/InfinityList.tsx b/src/components/complex/InfinityList/InfinityList.tsx new file mode 100644 index 000000000..aab5e271f --- /dev/null +++ b/src/components/complex/InfinityList/InfinityList.tsx @@ -0,0 +1,183 @@ +import React, { MutableRefObject, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; +import s from './InfinityList.module.css'; +import { useEvent } from '../../../shared/hooks/useEvent'; + +export type InfinityListRef = { + scrollTo: (index: number) => void; +}; +export type InfinityListVisibleItem = { + index: number; + value: T; +}; + +/** Props */ +export type InfinityListProps = React.HTMLAttributes & { + className?: string; + /** optional fixed height for the root (string or number) */ + height?: number | string; + items: T[]; + itemElement: React.ComponentType

; + itemHeight: number; + itemProps?: Omit; + innerRef?: MutableRefObject; + startLoading?: React.ReactNode; + endLoading?: React.ReactNode; + style: React.CSSProperties; + onEnd?: () => Promise; + onStart?: () => Promise; + reserve?: number; + overscan?: number; + getItemKey?: (value: T, index: number) => React.Key; +}; + +const DEFAULT_RESERVE = 100; +const DEFAULT_OVERSCAN = 2; + +const stringify = (items: InfinityListVisibleItem[]) => items.map((i) => i.index).join('_'); +const equalItems = (a: InfinityListVisibleItem[], b: InfinityListVisibleItem[]) => + a.length === b.length && stringify(a) === stringify(b); + +export const InfinityList = ({ + className, + height, + items, + itemElement: ItemElement, + itemProps = {} as P, + reserve = DEFAULT_RESERVE, + overscan = DEFAULT_OVERSCAN, + itemHeight, + onEnd, + onStart, + innerRef, + startLoading, + endLoading, + style, + ...props +}: InfinityListProps) => { + const root = useRef(null!); + const holder = useRef(null!); + + const [visibleItems, setVisibleItems] = useState[]>([]); + const prevScrollTop = useRef(null); + + const calcVisible = useEvent(() => { + const rootElem = root.current; + const holderElem = holder.current; + if (!rootElem || !holderElem) return; + const rootRect = rootElem.getBoundingClientRect(); + const holderRect = holderElem.getBoundingClientRect(); + const startIndex = Math.max(Math.floor((rootRect.top - holderRect.top) / itemHeight) - overscan, 0); + const visibleCount = Math.ceil(rootRect.height / itemHeight) + overscan * 2; + const next: InfinityListVisibleItem[] = []; + for (let i = startIndex; i < Math.min(items.length, startIndex + visibleCount); i++) { + next.push({ index: i, value: items[i] }); + } + setVisibleItems((prev) => (equalItems(prev, next) ? prev : next)); + }); + + const applied = useRef<{ end: boolean; start: boolean }>({ end: false, start: false }); + + const handleInfinityScroll = () => { + const rootRect = root.current.getBoundingClientRect(); + const holderRect = holder.current.getBoundingClientRect(); + const bottomDiff = holderRect.bottom - rootRect.bottom; + const topDiff = rootRect.top - holderRect.top; + if (prevScrollTop.current !== null) { + if (prevScrollTop.current < root.current.scrollTop && bottomDiff <= reserve) { + if (!applied.current.end && onEnd) { + applied.current.end = true; + onEnd().finally(() => { + applied.current.end = false; + }); + } + } else if (prevScrollTop.current > root.current.scrollTop && topDiff <= reserve) { + if (!applied.current.start && onStart) { + const prevHeight = holder.current.getBoundingClientRect().height; + applied.current.start = true; + onStart().finally(() => { + applied.current.start = false; + root.current.scrollBy({ top: holder.current.getBoundingClientRect().height - prevHeight }); + }); + } + } + } + prevScrollTop.current = root.current.scrollTop; + }; + + const commonCalc = useEvent(() => { + calcVisible(); + handleInfinityScroll(); + }); + + // recalc on items/height change + useLayoutEffect(commonCalc, [items, itemHeight, overscan, commonCalc]); + + const startLoadingCount = startLoading ? 1 : 0; + const endLoadingCount = endLoading ? 1 : 0; + + // ResizeObserver: recalc on container resize + useLayoutEffect(() => { + let raf = 0; + const fn = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(commonCalc); + }; + const ro = new ResizeObserver(fn); + if (root.current) ro.observe(root.current); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + }; + }, [commonCalc]); + + useImperativeHandle(innerRef, () => ({ + scrollTo: (index: number) => root.current.scrollTo({ top: (index - 1 + startLoadingCount) * itemHeight }), + })); + + const heightPx = typeof height === 'number' ? `${height}px` : height; + const mergedStyle = height ? { ...style, height: heightPx } : style; + + const heightHolder = itemHeight * (items.length + endLoadingCount + startLoadingCount); + + const startElem = startLoading && ( +

+ {startLoading} +
+ ); + + const endElem = endLoading && ( +
+ {endLoading} +
+ ); + + return ( +
+
+ {startElem} + {visibleItems.map((item) => { + const styleItem = { height: itemHeight, top: itemHeight * (item.index + startLoadingCount) }; + const key = (props as any).getItemKey?.(item.value, item.index) ?? item.index; + return ( +
+ +
+ ); + })} + {endElem} +
+
+ ); +}; +export default InfinityList; diff --git a/src/components/complex/InfinityList/index.ts b/src/components/complex/InfinityList/index.ts new file mode 100644 index 000000000..8772dbf21 --- /dev/null +++ b/src/components/complex/InfinityList/index.ts @@ -0,0 +1,2 @@ +export { default as InfinityList } from './InfinityList'; +export type { InfinityListProps, InfinityListRef } from './InfinityList'; diff --git a/src/components/dev/DiscountAdminPanel/DiscountAdminPanel.tsx b/src/components/dev/DiscountAdminPanel/DiscountAdminPanel.tsx new file mode 100644 index 000000000..06f1fcf01 --- /dev/null +++ b/src/components/dev/DiscountAdminPanel/DiscountAdminPanel.tsx @@ -0,0 +1,62 @@ + +import React from 'react'; +import { AccountService, ProductType, UserType } from '../../../test/account'; + +export type DiscountAdminPanelProps = { + service: AccountService; + onApplied?: () => void; +}; + +export const DiscountAdminPanel: React.FC = ({ service, onApplied }) => { + const [user, setUser] = React.useState(UserType.Standard); + const [product, setProduct] = React.useState(ProductType.Car); + const [g, setG] = React.useState('0'); + const [s, setS] = React.useState('0'); + const [err, setErr] = React.useState(''); + + const apply = async () => { + setErr(''); + try { + await service.setGlobal(user, Number(g)); + await service.setForProduct(user, product, Number(s)); + onApplied?.(); + } catch (e: any) { + setErr(e?.message ?? String(e)); + } + }; + + return ( +
+

Discount admin

+
+ + +
+
+ + setG(e.target.value)} /> +
+
+ + +
+
+ + setS(e.target.value)} /> +
+ + {err &&
{err}
} + +
+ + +
+
+ ); +}; + +export default DiscountAdminPanel; diff --git a/src/components/dev/DiscountAdminPanel/DiscountPlayground.stories.tsx b/src/components/dev/DiscountAdminPanel/DiscountPlayground.stories.tsx new file mode 100644 index 000000000..b514c8691 --- /dev/null +++ b/src/components/dev/DiscountAdminPanel/DiscountPlayground.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { AccountService, InMemoryDiscountRepository } from '../../../test/account'; +import DiscountAdminPanel from './DiscountAdminPanel'; +import DiscountPreview from '../DiscountPreview/DiscountPreview'; + +const meta: Meta = { + title: 'Dev/Discount Playground (Admin+Preview)', + parameters: { layout: 'padded' }, + tags: ['autodocs'], +} as any; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const repo = new InMemoryDiscountRepository(); + const svc = new AccountService(repo); + const [tick, setTick] = React.useState(0); + // seed baseline + void svc.setGlobal('Standard' as any, 5); + void svc.setGlobal('Premium' as any, 10); + void svc.setGlobal('Gold' as any, 15); + + return ( +
+ setTick(t => t+1)} /> + +
+ ); + } +}; diff --git a/src/components/dev/DiscountPreview/DiscountPreview.stories.tsx b/src/components/dev/DiscountPreview/DiscountPreview.stories.tsx new file mode 100644 index 000000000..aa0ec55e3 --- /dev/null +++ b/src/components/dev/DiscountPreview/DiscountPreview.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import DiscountPreview from './DiscountPreview'; + +const meta: Meta = { + title: 'Dev/DiscountPreview (AccountService)', + component: DiscountPreview, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; +export const Default: Story = { render: () => }; diff --git a/src/components/dev/DiscountPreview/DiscountPreview.tsx b/src/components/dev/DiscountPreview/DiscountPreview.tsx new file mode 100644 index 000000000..a9133663e --- /dev/null +++ b/src/components/dev/DiscountPreview/DiscountPreview.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { AccountService, InMemoryDiscountRepository, ProductType, UserType } from '../../../test/account'; + +function createDefaultService() { + const repo = new InMemoryDiscountRepository(); + const svc = new AccountService(repo); + // seed defaults for demo + void svc.setGlobal(UserType.Standard, 5); + void svc.setGlobal(UserType.Premium, 10); + void svc.setGlobal(UserType.Gold, 15); + void svc.setForProduct(UserType.Premium, ProductType.Car, 5); + void svc.setForProduct(UserType.Gold, ProductType.Food, 7); + return svc; +} + +export type DiscountPreviewProps = { + service?: AccountService; + refreshTrigger?: number; +}; + +export const DiscountPreview: React.FC = ({ service, refreshTrigger }) => { + const svc = React.useMemo(() => service ?? createDefaultService(), [service]); + const [user, setUser] = React.useState(UserType.Standard); + const [product, setProduct] = React.useState(ProductType.Car); + const [total, setTotal] = React.useState(0); + const [global, setGlobal] = React.useState(0); + const [specific, setSpecific] = React.useState(0); + + const recalc = React.useCallback(async () => { + setGlobal(await svc.getGlobal(user)); + setSpecific(await svc.getForProduct(user, product)); + setTotal(await svc.getTotal(user, product)); + }, [svc, user, product]); + + React.useEffect(() => { recalc(); }, [recalc, refreshTrigger]); + + return ( +
+

AccountService preview

+
+ + + + +
+ +
+
Глобальная скидка: {global}%
+
Скидка на товар: {specific}%
+
Итого: {total}%
+
+
+ ); +}; + +export default DiscountPreview; diff --git a/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css b/src/components/ecommerce/AddToCartButton/AddToCartButton.module.css new file mode 100644 index 000000000..06c6074c0 --- /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 16px; + 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/AddToCartButtonPattern.stories.tsx b/src/components/ecommerce/AddToCartButton/AddToCartButtonPattern.stories.tsx new file mode 100644 index 000000000..2399016d8 --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/AddToCartButtonPattern.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; +import AddToCartButtonPattern from './AddToCartButtonPattern'; + +const meta: Meta = { + title: 'Components/E-commerce/AddToCartButton (patterns)', + component: AddToCartButtonPattern, + tags: ['autodocs'], + parameters: { layout: 'centered' }, +}; +export default meta; +type Story = StoryObj; + +export const Uncontrolled: Story = { args: { defaultCount: 0 } }; + +export const Controlled: Story = { + render: () => { + const [count, setCount] = useState(0); + return ( +
+ +
Значение: {count}
+
+ + + +
+
+ ); + }, +}; diff --git a/src/components/ecommerce/AddToCartButton/AddToCartButtonPattern.tsx b/src/components/ecommerce/AddToCartButton/AddToCartButtonPattern.tsx new file mode 100644 index 000000000..918130d20 --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/AddToCartButtonPattern.tsx @@ -0,0 +1,68 @@ +import React, { forwardRef, useState } from 'react'; +import s from './AddToCartButton.module.css'; +import { AddToCartButtonExtendedProps } from '../../types'; + +const AddToCartButtonPattern = forwardRef(function AddToCartButtonPattern( + { count, defaultCount = 0, background, onChange, onAdd, onIncrease, onDecrease }, + ref +) { + const controlled = typeof count === 'number'; + const [inner, setInner] = useState(defaultCount); + const value = controlled ? (count as number) : inner; + + const set = (next: number) => { + if (!controlled) setInner(next); + onChange?.(next); + }; + + const handleAdd = () => { + onAdd?.(); + set(1); + }; + + const handleIncrease = () => { + onIncrease?.(); + set(value + 1); + }; + + const handleDecrease = () => { + onDecrease?.(); + set(Math.max(0, value - 1)); + }; + + if (value === 0) { + return ( + + ); + } + + return ( +
+ +
{value}
+ +
+ ); +}); + +export default AddToCartButtonPattern; diff --git a/src/components/ecommerce/AddToCartButton/index.ts b/src/components/ecommerce/AddToCartButton/index.ts new file mode 100644 index 000000000..6d1e0c3e6 --- /dev/null +++ b/src/components/ecommerce/AddToCartButton/index.ts @@ -0,0 +1,2 @@ +export { default as AddToCartButton } from './AddToCartButton'; +export { default as AddToCartButtonPattern } from './AddToCartButtonPattern'; \ 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..347bfa28c --- /dev/null +++ b/src/components/ecommerce/CartItem/CartItem.module.css @@ -0,0 +1,196 @@ +.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; +} + +.removeButton { + background:none; + border:none; + font-size:20px; + line-height:1; + cursor:pointer; +} + +.counterWrapper { + display:flex; + align-items:center;gap:8px; +} +.counter { + color: black; +} + +.changeButton { + padding:4px 8px; + cursor:pointer; +} + +/* Адаптивность */ +@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/CartItemPattern.stories.tsx b/src/components/ecommerce/CartItem/CartItemPattern.stories.tsx new file mode 100644 index 000000000..8c2eb1769 --- /dev/null +++ b/src/components/ecommerce/CartItem/CartItemPattern.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; +import CartItemPattern from './CartItemPattern'; +import { CartItem as CartItemType } from '../../types'; + +const meta: Meta = { + title: 'Components/E-commerce/CartItem (patterns)', + tags: ['autodocs'], + component: CartItemPattern, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +const sample: CartItemType = { + 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, +} as any; + +export const Uncontrolled: Story = { + render: () => console.log('remove')} />, +}; + +export const Controlled: Story = { + render: () => { + const [qty, setQty] = useState(2); + return setQty(0)} />; + }, +}; + +export const WithCustomActions: Story = { + render: () => { + const [qty, setQty] = useState(1); + return ( + setQty(0)} + renderActions={({ value, inc, dec, remove }) => ( +
+ + {value} + + +
+ )} + /> + ); + }, +}; diff --git a/src/components/ecommerce/CartItem/CartItemPattern.tsx b/src/components/ecommerce/CartItem/CartItemPattern.tsx new file mode 100644 index 000000000..df490410d --- /dev/null +++ b/src/components/ecommerce/CartItem/CartItemPattern.tsx @@ -0,0 +1,99 @@ +import React, { useState, memo } from 'react'; +import s from './CartItem.module.css'; +import { CartItemEnhancedProps } from '../../types'; + +const CartItemPattern: React.FC = ({ + item, + quantity, + defaultQuantity, + onQuantityChange, + onRemove, + disableRemove = false, + renderActions, +}) => { + const { name, description, price, image } = item; + const controlled = typeof quantity === 'number'; + const [inner, setInner] = useState( + defaultQuantity ?? (item as any).quantity ?? 1 + ); + const value = controlled ? (quantity as number) : inner; + + const set = (next: number) => { + const n = Math.max(0, next); + if (!controlled) setInner(n); + onQuantityChange?.(n); + }; + const inc = () => set(value + 1); + const dec = () => set(value - 1); + const remove = () => onRemove?.(); + + const totalPrice = price * value; + + return ( +
+
+ {name} +
+
+
+

{name}

+ {!disableRemove && ( + + )} +
+ +
+

Описание:

+

{description}

+
+ +
+
+ Количество:{' '} + + {value} + +
+ +
+ {renderActions ? ( + renderActions({ value, inc, dec, remove }) + ) : ( +
+ +
{value}
+ +
+ )} +
+ +
+
{price.toLocaleString('ru-RU')} ₽ за шт.
+
{totalPrice.toLocaleString('ru-RU')} ₽
+
+
+
+
+ ); +}; + +export default memo(CartItemPattern); diff --git a/src/components/ecommerce/CartItem/index.ts b/src/components/ecommerce/CartItem/index.ts new file mode 100644 index 000000000..5ead4353f --- /dev/null +++ b/src/components/ecommerce/CartItem/index.ts @@ -0,0 +1,2 @@ +export { default as CartItem } from './CartItem'; +export { default as CartItemPattern } from './CartItemPattern'; \ 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..786127847 --- /dev/null +++ b/src/components/ecommerce/ProductBrief/ProductBrief.module.css @@ -0,0 +1,123 @@ +.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; +} + +.actions{ + margin-top:8px; + display:flex; + gap:8px; +} + +/* Адаптивность */ +@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..9b2882185 --- /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, children, ...rest }) => { + const { name, description, price, image } = product; + + const truncatedDesc = + description.length > maxDescriptionLength + ? description.slice(0, maxDescriptionLength) + '...' + : description; + + return ( +
+
+ {name} +
+ +
+

{name}

+

+ {truncatedDesc} +

+ +
+
{price.toLocaleString('ru-RU')} ₽
+ {children ?? } +
+
+
+ ); +}; +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..72f0fc261 --- /dev/null +++ b/src/components/ecommerce/ProductFull/ProductFull.tsx @@ -0,0 +1,33 @@ +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/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..c81a7fc5b --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductList.stories.tsx @@ -0,0 +1,41 @@ +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..cf956c7aa --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductList.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, 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/ProductListPattern.stories.tsx b/src/components/ecommerce/ProductList/ProductListPattern.stories.tsx new file mode 100644 index 000000000..81811856d --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductListPattern.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useMemo } from 'react'; +import { ProductListPattern } from '.'; +import { Product } from '../../types'; +import { AddToCartButton } from '../AddToCartButton'; +import { ProductBrief } from '../ProductBrief'; + +const meta: Meta = { + title: 'Components/E-commerce/ProductList (patterns)', + component: ProductListPattern, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + argTypes: { + items: { control: { type: 'number', min: 0, max: 120, step: 4 } }, + pageSize: { control: { type: 'number', min: 1, max: 48, step: 1 } }, + useInfinite: { control: 'boolean' }, + }, +}; +export default meta; +type Story = StoryObj; + +function makeProduct(i: number): Product { + return { + name: 'Demo ' + i, + description: 'Описание товара ' + i, + price: 1000 + i, + image: 'https://picsum.photos/seed/p' + i + '/320/240', + category: i % 2 ? 'electronics' : 'home', + }; +} + +export const Default: Story = { + args: { items: 30, pageSize: 8, useInfinite: true } as any, + render: (args) => { + const all = useMemo(() => Array.from({ length: (args as any).items }, (_, i) => makeProduct(i + 1)), [args]); + return ( + ( + + + + )} + /> + ); + }, +}; diff --git a/src/components/ecommerce/ProductList/ProductListPattern.tsx b/src/components/ecommerce/ProductList/ProductListPattern.tsx new file mode 100644 index 000000000..0c15e308e --- /dev/null +++ b/src/components/ecommerce/ProductList/ProductListPattern.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useRef, useState, memo, useCallback } from 'react'; +import styles from './ProductList.module.css'; +import { Product, ProductListPatternProps } from '../../types'; +import { ProductBrief } from '../ProductBrief'; +import { List } from '../../patterns/List'; +import { useIntersection } from '../../../shared/hooks/useIntersection'; + +const DEFAULT_PAGE = 12; + +const ProductListPattern: React.FC = ({ + items, + pageSize = DEFAULT_PAGE, + useInfinite = false, + renderItem, +}) => { + const [data, setData] = useState(() => items.slice(0, Math.min(items.length, pageSize))); + const [hasMore, setHasMore] = useState(items.length > pageSize); + const loadingRef = useRef(false); + + const append = useCallback( + (count: number) => { + if (loadingRef.current) return; + loadingRef.current = true; + const next = items.slice(0, Math.min(items.length, data.length + count)); + setData(next); + setHasMore(next.length < items.length); + setTimeout(() => { + loadingRef.current = false; + }, 0); + }, + [items, data.length] + ); + + // обновлять при смене входных items/pageSize + useEffect(() => { + const first = items.slice(0, Math.min(items.length, pageSize)); + setData(first); + setHasMore(items.length > pageSize); + }, [items, pageSize]); + + const { ref: sentinelRef } = useIntersection({ + enabled: useInfinite && hasMore, + rootMargin: '200px', + threshold: 0, + onIntersect: () => append(pageSize), + }); + + return ( +
+
Нет товаров
}> + {(p, idx) => ( +
  • + {renderItem ? renderItem(p, idx) : } +
  • + )} +
    + + {hasMore && ( + <> +
    +
    + +
    + + )} +
    + ); +}; + +export default memo(ProductListPattern); diff --git a/src/components/ecommerce/ProductList/index.ts b/src/components/ecommerce/ProductList/index.ts new file mode 100644 index 000000000..0131b20c2 --- /dev/null +++ b/src/components/ecommerce/ProductList/index.ts @@ -0,0 +1,2 @@ +export { default as ProductList } from './ProductList'; +export { default as ProductListPattern } from './ProductListPattern'; diff --git a/src/components/ecommerce/index.ts b/src/components/ecommerce/index.ts new file mode 100644 index 000000000..7c55a55d7 --- /dev/null +++ b/src/components/ecommerce/index.ts @@ -0,0 +1,6 @@ +export { AddToCartButton } from './AddToCartButton'; +export { AddToCartButtonPattern } from './AddToCartButton'; +export { CartItem, CartItemPattern } from './CartItem'; +export { ProductBrief } from './ProductBrief'; +export { ProductFull } from './ProductFull'; +export { ProductList, ProductListPattern } from './ProductList'; \ No newline at end of file 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..8024ceae2 --- /dev/null +++ b/src/components/finance/Operations/Operation/Operation.module.css @@ -0,0 +1,72 @@ + +.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..061c04e38 --- /dev/null +++ b/src/components/finance/Operations/Operation/Operation.stories.tsx @@ -0,0 +1,26 @@ +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..bdb5c4869 --- /dev/null +++ b/src/components/finance/Operations/Operation/Operation.tsx @@ -0,0 +1,26 @@ +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..e314787ce --- /dev/null +++ b/src/components/finance/Operations/OperationFull/OperationFull.module.css @@ -0,0 +1,95 @@ + +.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..a04ff5a86 --- /dev/null +++ b/src/components/finance/Operations/OperationFull/OperationFull.stories.tsx @@ -0,0 +1,26 @@ +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..a49c05874 --- /dev/null +++ b/src/components/finance/Operations/OperationFull/OperationFull.tsx @@ -0,0 +1,58 @@ +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}
    +
    +
    +
    Дата
    +
    + +
    +
    +
    +
    Описание
    +
    +