diff --git a/package-lock.json b/package-lock.json index 7b51046..f65a472 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,14 @@ }, "devDependencies": { "@minify-html/node": "^0.18.1", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", "easy-https": "^1.0.1", "javascript-obfuscator": "^5.5.0", + "typescript": "^7.0.2", + "vite": "^8.2.2", "ws": "^8.20.1" } }, @@ -208,6 +214,278 @@ "win32" ] }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -215,6 +493,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -222,6 +530,346 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vercel/blob": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.8.0.tgz", @@ -279,6 +927,36 @@ "node": ">= 20" } }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -690,6 +1368,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -880,6 +1568,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -923,6 +1629,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1448,6 +2169,267 @@ "dev": true, "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1553,6 +2535,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -1709,6 +2710,26 @@ "node": ">=8" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -1732,6 +2753,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -1833,6 +2883,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -2006,6 +3090,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/string-template": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", @@ -2100,6 +3194,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2120,6 +3231,41 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", @@ -2130,6 +3276,13 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/union": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", @@ -2171,6 +3324,84 @@ "node": ">= 0.10" } }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", diff --git a/package.json b/package.json index b58660e..91c031c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "scripts": { - "start": "./node_modules/http-server/bin/http-server -p 9876 -S -C local.hvatilo.ru.pem -K local.hvatilo.ru-key.pem -o .", - "build": "./build.js" + "start": "vite", + "build": "vite build" }, "dependencies": { "http-server": "^14.1.1", @@ -11,8 +11,14 @@ }, "devDependencies": { "@minify-html/node": "^0.18.1", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", "easy-https": "^1.0.1", "javascript-obfuscator": "^5.5.0", + "typescript": "^7.0.2", + "vite": "^8.2.2", "ws": "^8.20.1" } } diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..0e464bd --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { loadInitialData, setOption } from './api/promoApi'; +import { Cart } from './components/Cart/Cart'; +import { CookiePopup } from './components/CookiePopup/CookiePopup'; +import { FamilySection } from './components/Family/FamilySection'; +import { CategoriesSection } from './components/Categories/CategoriesSection'; +import { DaysSection } from './components/Days/DaysSection'; +import { Accordion } from './components/Accordion/Accordion'; +import { normalizeProductImage } from './utils/product'; +import type { InitialData } from './types/api'; +import type { Product } from './types/cart'; +import type { User } from './types/user'; + +export function App() { + const [initialData, setInitialData] = useState(null); + const [products, setProducts] = useState([]); + const [isCartComplete, setIsCartComplete] = useState(false); + const [isCartLoading, setIsCartLoading] = useState(true); + const [openSection, setOpenSection] = useState('family'); + + useEffect(() => { + loadInitialData().then(data => { + setInitialData(data); + setProducts(data.cart.flat().map(product => normalizeProductImage(product))); + }); + }, []); + + const users = initialData?.users ?? []; + const categories = initialData?.available_groups ?? []; + const selectedCategories = initialData?.initial_groups ?? []; + const days = initialData?.days ?? 7; + + const handleDaysChange = useCallback(async (nextDays: number) => { + setInitialData(current => current ? { ...current, days: nextDays } : current); + await setOption('days', nextDays); + }, []); + + const handleUsersChange = useCallback(async (nextUsers: User[]) => { + setInitialData(current => current ? { ...current, users: nextUsers } : current); + await setOption('users', nextUsers); + }, []); + + const handleCategoriesChange = useCallback((nextCategories: string[]) => { + setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current); + }, []); + + const pageContent = useMemo(() => { + if (!initialData) { + return Загрузка...; + } + + return ( + + + +

Автокорзина

+

Собираем продукты под ваши настройки

+
+ + { + setIsCartComplete(true); + setIsCartLoading(false); + }} + onLoadingChange={setIsCartLoading} + /> +
+ + + +

Настройте корзину под себя

+

Чем больше вы расскажете, тем точнее будет подбор

+
+ + + } + isOpen={openSection === 'days'} + onToggle={() => setOpenSection(openSection === 'days' ? '' : 'days')} + > + + + + 0 + ? `${users.length} человек` + : 'Не указаны' + } + icon={семья} + isOpen={openSection === 'family'} + onToggle={() => setOpenSection(openSection === 'family' ? '' : 'family')} + > + + + + 0 + ? `Выбраны ${selectedCategories.length}` + : 'Не указаны' + } + isHighlighted={selectedCategories.length > 0} + isOpen={openSection === 'preferences'} + onToggle={() => setOpenSection(openSection === 'preferences' ? '' : 'preferences')} + > + undefined} + /> + + +
+
+ ); + }, [ + categories, + days, + handleCategoriesChange, + handleDaysChange, + handleUsersChange, + initialData, + isCartComplete, + isCartLoading, + openSection, + products, + selectedCategories.length, + users, + ]); + + return ( + <> + {pageContent} + + + ); +} + +const LoadingPage = styled.div` + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.colors.white}; + font-size: 20px; +`; + +const Container = styled.main` + position: relative; + display: flex; + width: calc(100% - 40px); + max-width: 1228px; + height: 90vh; + margin: 40px auto 0; + background: ${({ theme }) => theme.colors.white}; + border-radius: ${({ theme }) => theme.radius.page}; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04); + overflow: hidden; + + @media (max-width: 900px) { + flex-direction: column; + height: auto; + min-height: 90vh; + } +`; + +const Column = styled.section` + padding: 40px; + flex: 1; + width: 50%; + overflow-y: auto; + + & + & { + border-left: 1px solid #f0f0f0; + } + + @media (max-width: 900px) { + width: 100%; + padding: 20px; + + & + & { + border-left: none; + border-top: 1px solid #f0f0f0; + } + } +`; + +const LeftColumn = styled(Column)` + display: flex; + flex-direction: column; + height: 100%; + max-width: 490px; +`; + +const RightColumn = styled(Column)``; + +const TitleBlock = styled.div` + margin-bottom: 32px; + + h1 { + font-size: 32px; + line-height: 38px; + font-weight: 700; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-bottom: 8px; + } + + p { + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.colors.gray}; + font-weight: 350; + } +`; + +const FilterTitle = styled.div` + margin-bottom: 40px; + + h2 { + font-size: 20px; + line-height: 24px; + margin-bottom: 8px; + color: ${({ theme }) => theme.colors.darkGreen}; + } + + p { + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.colors.gray}; + font-weight: 350; + } +`; + +const SettingsPanel = styled.div` + border: 1px solid ${({ theme }) => theme.colors.border}; + border-radius: 16px; +`; \ No newline at end of file diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..1428850 --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,7 @@ +export const API_HOST = 'https://local.hvatilo.ru:8055'; + +export const jsonHeaders = { + 'Content-Type': 'application/json', +}; + +export const cspNonce = '**MY_PRETTY_CSP_NONCE**'; \ No newline at end of file diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..f08b8e2 --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,31 @@ +import { API_HOST } from './config'; +import { refreshToken } from './tokenApi'; + +export function apiUrl(path: string): string { + return `${API_HOST}${path}`; +} + +export async function requestWithRefresh( + url: string, + options: RequestInit = {}, +): Promise { + const response = await fetch(url, { + ...options, + credentials: 'include', + }); + + const data = await response.json(); + + if (data?.error_message === 'need_refresh') { + await refreshToken(); + + const retryResponse = await fetch(url, { + ...options, + credentials: 'include', + }); + + return await retryResponse.json() as Promise; + } + + return Promise.resolve(data) as Promise; +} \ No newline at end of file diff --git a/src/api/promoApi.ts b/src/api/promoApi.ts new file mode 100644 index 0000000..9a1a419 --- /dev/null +++ b/src/api/promoApi.ts @@ -0,0 +1,119 @@ +import { apiUrl, requestWithRefresh } from './http'; +import { jsonHeaders } from './config'; +import { initToken, refreshToken } from './tokenApi'; +import type { CartUpdateResponse, InitialData } from '../types/api'; + +export async function loadInitialData(): Promise { + try { + const response = await fetch(apiUrl('/api/promo/get_initial_data'), { + credentials: 'include', + }); + + const data = await response.json(); + + if (response.status === 400) { + await clearGuestCookie(); + document.location.reload(); + return fallbackInitialData; + } + + if (response.status === 401 && data.error_message !== 'need_refresh') { + await initToken(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const retryResponse = await fetch(apiUrl('/api/promo/get_initial_data'), { + credentials: 'include', + }); + + return retryResponse.json(); + } + + if (response.status === 403 || data.error_message === 'need_refresh') { + await refreshToken(); + + const retryResponse = await fetch(apiUrl('/api/promo/get_initial_data'), { + credentials: 'include', + }); + + return retryResponse.json(); + } + + return data; + } catch { + return fallbackInitialData; + } +} + +export function getCartUpdates(): Promise { + return new Promise((resolve, reject) => { + // Use a simple mutex to ensure only one request is active at a time + let isPending = false; + let pendingRequest: Promise | null = null; + + if (isPending) { + // If already pending, reject the current request and return a placeholder + return reject(new Error('getCartUpdates is already in progress')); + } + + isPending = true; + + // Create a new request that will resolve when the actual request completes + pendingRequest = requestWithRefresh(apiUrl('/api/promo/get_updates'), { + headers: jsonHeaders, + }).then( + (response) => { + isPending = false; + resolve(response as CartUpdateResponse); + return response; + }, + (error) => { + isPending = false; + reject(error); + return null; + } + ); + + // Return the promise so the caller can wait for it + return pendingRequest; + }); +} + +export function replaceProduct(productName: string): Promise { + return requestWithRefresh(apiUrl('/api/promo/replace_item'), { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ product_name: productName }), + }); +} + +export function deleteProduct(productName: string): Promise { + return requestWithRefresh(apiUrl('/api/promo/delete_item'), { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ product_name: productName }), + }); +} + +export function setOption(key: string, value: TValue): Promise { + return requestWithRefresh(apiUrl('/api/promo/set_option'), { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ [key]: value }), + }); +} + +export function clearGuestCookie(): Promise { + return requestWithRefresh(apiUrl('/api/guest/clear_cookie'), { + method: 'POST', + }); +} + +const fallbackInitialData: InitialData = { + cart: [], + users: [], + initial_groups: [], + available_groups: [], + days: 7, + excess: 0, + precision: 0, +} \ No newline at end of file diff --git a/src/api/tokenApi.ts b/src/api/tokenApi.ts new file mode 100644 index 0000000..cbc57a1 --- /dev/null +++ b/src/api/tokenApi.ts @@ -0,0 +1,30 @@ +import { API_HOST } from './config'; + +export async function initToken(): Promise { + const response = await fetch(`${API_HOST}/api/guest/init_cookie?utm_source=hvatilo_promo`, { + method: 'POST', + credentials: 'include', + }); + + return response.json(); +} + +export async function refreshToken(): Promise { + const response = await fetch(`${API_HOST}/api/guest/refresh_cookie`, { + method: 'POST', + credentials: 'include', + }); + + if (response.status === 400) { + await fetch(`${API_HOST}/api/guest/clear_cookie`, { + method: 'POST', + credentials: 'include', + }); + + await new Promise(resolve => setTimeout(resolve, 100)); + + return initToken(); + } + + return response.json(); +} \ No newline at end of file diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 0000000..61920dc --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react'; +import styled from 'styled-components'; +import { ArrowIcon } from '../Icons/ArrowIcon'; +import { HeartIcon } from '../Icons/HeartIcon'; + +interface AccordionProps { + id: string; + title: string; + description?: string; + icon?: ReactNode; + isOpen: boolean; + isHighlighted?: boolean; + onToggle: () => void; + children: ReactNode; +} + +export function Accordion({ + title, + description, + icon, + isOpen, + isHighlighted = true, + onToggle, + children, + }: AccordionProps) { + return ( + +
+ + + {icon ?? } + + +
+ {title} + {description && {description}} +
+
+ + + + +
+ + + {children} + +
+ ); +} + +const Item = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.colors.border}; + position: relative; + + &:last-child { + border-bottom: none; + } +`; + +const Header = styled.button` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + cursor: pointer; + user-select: none; + background: transparent; + border: 0; + text-align: left; +`; + +const HeaderLeft = styled.div` + display: flex; + align-items: center; +`; + +const IconCircle = styled.div<{ $highlighted: boolean }>` + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + flex-shrink: 0; + background: ${({ $highlighted }) => ($highlighted ? '#E6F696' : '#E7EDE7')}; + + img { + width: 44px; + height: 44px; + } + + svg rect { + fill: ${({ $highlighted }) => ($highlighted ? '#E6F696' : '#E7EDE7')}; + } + + svg path { + stroke: ${({ $highlighted }) => ($highlighted ? '#5EA12D' : '#A3ADA2')}; + } +`; + +const HeaderTitle = styled.div` + font-size: 16px; + line-height: 19px; + font-weight: 500; + color: ${({ theme }) => theme.colors.darkGreen}; +`; + +const Description = styled.div` + font-size: 14px; + color: ${({ theme }) => theme.colors.gray}; + margin-top: 2px; +`; + +const ArrowWrap = styled.div<{ $open: boolean }>` + color: ${({ theme }) => theme.colors.gray}; + transition: transform 0.3s ease; + transform: ${({ $open }) => ($open ? 'rotate(180deg)' : 'rotate(0deg)')}; + + svg { + width: 16px; + height: 16px; + } +`; + +const Content = styled.div<{ $open: boolean }>` + max-height: ${({ $open }) => ($open ? '500px' : '0')}; + overflow: hidden; + transition: max-height 0.4s ease, padding 0.3s ease; +`; + +const ContentInner = styled.div` + padding: 0 24px 20px; +`; \ No newline at end of file diff --git a/src/components/Cart/Cart.tsx b/src/components/Cart/Cart.tsx new file mode 100644 index 0000000..6fc0fdc --- /dev/null +++ b/src/components/Cart/Cart.tsx @@ -0,0 +1,297 @@ +import {Dispatch, SetStateAction, useMemo, useState} from 'react'; +import styled from 'styled-components'; +import { deleteProduct, replaceProduct } from '../../api/promoApi'; +import { useCartPolling } from '../../hooks/useCartPolling'; +import { declension } from '../../utils/declension'; +import { formatCurrency } from '../../utils/formatCurrency'; +import type { Product } from '../../types/cart'; +import { CartItem } from './CartItem'; +import { CartFooter } from './CartFooter'; +import { ProductModal } from './ProductModal'; + +interface CartProps { + products: Product[]; + days: number; + excess: number; + isComplete: boolean; + isLoading: boolean; + onProductsChange: Dispatch>; + onComplete: () => void; + onLoadingChange: (isLoading: boolean) => void; +} + +export function Cart({ + products, + days, + excess, + isComplete, + isLoading, + onProductsChange, + onComplete, + onLoadingChange, + }: CartProps) { + const [visibleCount, setVisibleCount] = useState(5); + const [displayText, setDisplayText] = useState(''); + const [selectedProduct, setSelectedProduct] = useState(null); + const [processingProductName, setProcessingProductName] = useState(null); + + const visibleProducts = products.slice(0, visibleCount); + const remaining = products.length - visibleProducts.length; + + const totalPrice = useMemo( + () => products.reduce((sum, product) => sum + product.total_price, 0), + [products], + ); + + const formattedTotalPrice = formatCurrency(totalPrice); + const stockDays = Math.round(days * excess + days); + + useCartPolling({ + enabled: !isComplete, + onProductsChange, + onComplete, + onLoadingChange, + setDisplayText, + }); + + async function handleReplace(product: Product) { + setProcessingProductName(product.name); + + try { + await replaceProduct(product.name); + onProductsChange(products.filter(item => item.name !== product.name)); + } finally { + setProcessingProductName(null); + } + } + + async function handleDelete(product: Product) { + setProcessingProductName(product.name); + + try { + await deleteProduct(product.name); + onProductsChange(products.filter(item => item.name !== product.name)); + } finally { + setProcessingProductName(null); + } + } + + return ( + <> + +
+ + Ваша корзина на {days} {declension(days, ['день', 'дня', 'дней'])} + + + + {products.length > 0 && ( + <> + + {products.length} {declension(products.length, ['товар', 'товара', 'товаров'])} + + + {formattedTotalPrice} + + С запасами на {stockDays} {declension(stockDays, ['день', 'дня', 'дней'])} + + + )} + + + + {displayText} + +
+
+ + + + {visibleProducts.map(product => ( + setSelectedProduct(product)} + onReplace={() => handleReplace(product)} + onDelete={() => handleDelete(product)} + /> + ))} + + + {products.length === 0 && ( + + )} + + + {remaining > 0 && ( + setVisibleCount(products.length)}> + Показать ещё {remaining} товаров + + + + + )} + + + + setSelectedProduct(null)} + onReplace={handleReplace} + onAnother={() => undefined} + /> + + ); +} + +const Summary = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-shrink: 0; + width: 100%; +`; + +const SummaryTitle = styled.div` + font-size: 20px; + line-height: 24px; + font-weight: 600; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-bottom: 8px; +`; + +const Green = styled.span` + color: ${({ theme }) => theme.colors.green}; +`; + +const SummaryDetails = styled.div<{ $hidden: boolean }>` + min-height: 24px; + font-size: 14px; + color: ${({ theme }) => theme.colors.gray}; + display: flex; + justify-content: start; + align-items: center; + gap: 8px; + visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')}; + + span { + font-weight: 350; + } + + b { + font-family: initial; + font-size: 20px; + line-height: 8px; + margin-bottom: -2px; + } +`; + +const SummaryExcess = styled.div` + background-color: ${({ theme }) => theme.colors.paleGreen}; + padding: 3px 6px 5px; + color: #6D8982; + border-radius: 4px; + font-size: 14px; + font-weight: 350; +`; + +const Loader = styled.div<{ $hidden: boolean, "data-display-text": string }>` + font-size: 16px; + line-height: 19px; + display: inline-block; + color: transparent; + background-clip: text; + position: absolute; + visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')}; + + &:before, + &:after { + content: '${({"data-display-text": displayText}) => displayText}'; + display: block; + position: absolute; + inset: 0; + } + + &:before { + background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%; + background-clip: text; + animation: opacityAnim 1s ease-in-out 0s infinite alternate; + } + + &:after { + background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%; + background-clip: text; + animation: opacityAnim 1s ease-in-out -1s infinite alternate; + } +`; + +const ScrollWrapper = styled.div` + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + margin-bottom: 10px; + position: relative; + display: flex; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 4px; + } +`; + +const CartList = styled.ul` + width: 100%; + list-style: none; +`; + +const EmptyLoader = styled.img` + animation: spin 1.4s cubic-bezier(0.4, 0.2, 0.35, 0.7) infinite; + position: absolute; + left: 50%; + margin-left: -30px; + align-self: center; +`; + +const ShowMore = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 20px 0 10px; + color: ${({ theme }) => theme.colors.green}; + font-size: 14px; + cursor: pointer; + font-weight: 500; + transition: 0.2s; + flex-shrink: 0; + gap: 4px; + border: 0; + background: transparent; + + &:hover { + opacity: 0.8; + } + + svg { + width: 14px; + height: 14px; + stroke: ${({ theme }) => theme.colors.green}; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } +`; \ No newline at end of file diff --git a/src/components/Cart/CartFooter.tsx b/src/components/Cart/CartFooter.tsx new file mode 100644 index 0000000..3837461 --- /dev/null +++ b/src/components/Cart/CartFooter.tsx @@ -0,0 +1,99 @@ +import styled from 'styled-components'; +import {BagIcon} from "../Icons/BagIcon"; + + +interface CartFooterProps { + isVisible: boolean; + totalPrice: string; +} + +export function CartFooter({ isVisible, totalPrice }: CartFooterProps) { + return ( +
+ +
+ + + + +
+ Итого + {totalPrice} +
+
+
+ + + Оформить заказ + +
+ ); +} + +const Footer = styled.div<{ $visible: boolean }>` + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 20px; + border-top: 1px solid #f0f0f0; + flex-shrink: 0; + + & > * { + visibility: ${({ $visible }) => ($visible ? 'visible' : 'hidden')}; + } +`; + +const TotalWrap = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + + & > div { + display: flex; + align-items: center; + } +`; + +const IconCircle = styled.div` + width: 44px; + height: 44px; + border-radius: 50%; + margin-right: 8px; +`; + +const TotalLabel = styled.span` + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.colors.darkGreen}; +`; + +const TotalPrice = styled.div` + font-size: 20px; + line-height: 24px; + font-weight: 500; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-top: 4px; +`; + +const CheckoutButton = styled.button` + background-color: ${({ theme }) => theme.colors.green}; + height: 54px; + color: #ffffff; + border: none; + padding: 14px 32px; + border-radius: 12px; + font-size: 16px; + font-weight: 370; + line-height: 20px; + cursor: pointer; + transition: background 0.2s; + + &:hover, + &:focus { + background-color: ${({ theme }) => theme.colors.greenHover}; + } + + &:active { + background-color: #4A8D19; + } +`; \ No newline at end of file diff --git a/src/components/Cart/CartItem.tsx b/src/components/Cart/CartItem.tsx new file mode 100644 index 0000000..37a2257 --- /dev/null +++ b/src/components/Cart/CartItem.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import { getProductAmount } from '../../utils/product'; +import type { Product } from '../../types/cart'; + +interface CartItemProps { + product: Product; + isProcessing: boolean; + onOpen: () => void; + onReplace: () => void; + onDelete: () => void; +} + +export function CartItem({ + product, + isProcessing, + onOpen, + onReplace, + onDelete, + }: CartItemProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + + + + + + + {product.name} + + {getProductAmount(product)} × {Math.round(product.price * 100) / 100} ₽ + + + + + setIsMenuOpen(value => !value)} + > + ⋮ + + + {isMenuOpen && ( + +
    +
  • + +
  • +
  • + +
  • +
+
+ )} +
+
+ ); +} + +const Item = styled.li<{ $processing: boolean }>` + display: flex; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid #f0f0f0; + position: relative; + transition: opacity 0.4s, transform 0.4s; + opacity: ${({ $processing }) => ($processing ? 0.5 : 1)}; + pointer-events: ${({ $processing }) => ($processing ? 'none' : 'auto')}; +`; + +const ItemImage = styled.button` + width: 72px; + height: 65px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 16px; + flex-shrink: 0; + position: relative; + overflow: hidden; + border: 0; + background: transparent; + cursor: pointer; + + img { + max-width: 100%; + max-height: 100%; + } +`; + +const ItemDetails = styled.div` + flex: 1; +`; + +const ItemName = styled.div` + font-size: 15px; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-bottom: 4px; +`; + +const ItemPrice = styled.div` + font-size: 14px; + color: ${({ theme }) => theme.colors.gray}; +`; + +const MenuWrapper = styled.div` + position: relative; +`; + +const MenuButton = styled.button` + background: none; + border: none; + color: #b0b0b0; + cursor: pointer; + padding: 4px 8px; + font-size: 20px; + letter-spacing: 2px; + border-radius: 4px; + transition: 0.2s; + + &:hover { + background: #f3f4f6; + color: ${({ theme }) => theme.colors.darkGreen}; + } +`; + +const Dropdown = styled.div` + position: absolute; + right: 0; + top: 100%; + background: #ffffff; + border: 1px solid #eee; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + min-width: 150px; + padding: 6px 0; + z-index: 10; + margin-top: 4px; + + ul { + list-style: none; + } + + button { + width: 100%; + padding: 10px 16px; + font-size: 14px; + color: ${({ theme }) => theme.colors.darkGreen}; + cursor: pointer; + transition: 0.2s; + background: transparent; + border: 0; + text-align: left; + + &:hover { + background: #f3f4f6; + } + } +`; \ No newline at end of file diff --git a/src/components/Cart/ProductModal.tsx b/src/components/Cart/ProductModal.tsx new file mode 100644 index 0000000..d8a6b8c --- /dev/null +++ b/src/components/Cart/ProductModal.tsx @@ -0,0 +1,150 @@ +import styled from 'styled-components'; +import { formatCurrency } from '../../utils/formatCurrency'; +import { getLargeProductImage } from '../../utils/product'; +import type { Product } from '../../types/cart'; + +interface ProductModalProps { + product: Product | null; + onClose: () => void; + onReplace: (product: Product) => void; + onAnother: (product: Product) => void; +} + +export function ProductModal({ + product, + onClose, + onReplace, + onAnother, + }: ProductModalProps) { + if (!product) return null; + + return ( + + event.stopPropagation()}> + + × + + + + {product.name} + + + {product.name} + +
+ {product.n} · {formatCurrency(product.total_price)} +
+ + onReplace(product)}> + Заменить + + + { + event.preventDefault(); + onAnother(product); + }} + > + Ещё вариант + +
+
+ ); +} + +const Overlay = styled.div` + position: fixed; + display: flex; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + justify-content: center; + align-items: center; + z-index: 1000; +`; + +const Window = styled.div` + background-color: #ffffff; + border-radius: 12px; + width: 100%; + max-width: 340px; + padding: 20px 24px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + position: relative; +`; + +const CloseButton = styled.button` + position: absolute; + top: 15px; + right: 15px; + background: none; + border: none; + color: #b3b3b3; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 5px; + + &:hover { + color: #777; + } +`; + +const ImageWrap = styled.div` + display: flex; + justify-content: center; + margin: 20px 0; + + img { + width: 160px; + height: auto; + object-fit: contain; + } +`; + +const Title = styled.h2` + font-size: 18px; + font-weight: 700; + text-align: center; + color: #222222; + line-height: 1.4; + margin: 0 0 10px; +`; + +const Details = styled.p` + font-size: 15px; + text-align: center; + color: #555555; + margin: 0 0 24px; +`; + +const ActionButton = styled.button` + display: block; + width: 100%; + background-color: #5ba653; + color: #ffffff; + font-size: 16px; + font-weight: 600; + padding: 14px 0; + border: none; + border-radius: 8px; + cursor: pointer; + + &:hover { + background-color: #4e9247; + } +`; + +const AnotherButton = styled.a` + display: block; + text-align: center; + margin-top: 15px; + color: #5ba653; + font-size: 14px; + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } +`; \ No newline at end of file diff --git a/src/components/Categories/CategoriesSection.tsx b/src/components/Categories/CategoriesSection.tsx new file mode 100644 index 0000000..ab011b2 --- /dev/null +++ b/src/components/Categories/CategoriesSection.tsx @@ -0,0 +1,213 @@ +import { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { setOption } from '../../api/promoApi'; +import { useDebounceBatch } from '../../hooks/useDebounceBatch'; + +interface CategoriesSectionProps { + categories: string[]; + selectedCategories: string[]; + onLocalChange: (categories: string[]) => void; + onSaved: () => void; +} + +export function CategoriesSection({ + categories, + selectedCategories, + onLocalChange, + onSaved, + }: CategoriesSectionProps) { + const [expanded, setExpanded] = useState(false); + const [loadingCategories, setLoadingCategories] = useState([]); + + const maxVisibleCategories = 7; + + const dataToShow = useMemo(() => { + if (expanded) return categories; + + return (selectedCategories.length > 0 ? selectedCategories : categories) + .slice(0, maxVisibleCategories); + }, [categories, expanded, selectedCategories]); + + const sendUpdatedCategories = useDebounceBatch(async categoryNames => { + let nextCategories = [...selectedCategories]; + + categoryNames.forEach(categoryName => { + if (nextCategories.includes(categoryName)) { + nextCategories = nextCategories.filter(item => item !== categoryName); + } else { + nextCategories.push(categoryName); + } + }); + + onLocalChange(nextCategories); + await setOption('categories', nextCategories); + onSaved(); + }, 1500); + + function toggleCategory(category: string) { + if (loadingCategories.includes(category)) return; + + setLoadingCategories(current => [...current, category]); + + sendUpdatedCategories(category).finally(() => { + setLoadingCategories(current => current.filter(item => item !== category)); + }); + } + + if (categories.length === 0) { + return null; + } + + return ( + +
+

Что добавить в корзину?

+

Выберите группы продуктов, которые хотите видеть в корзине

+
+ + + + {dataToShow.map(category => ( + toggleCategory(category)} + > + {category} + + ))} + + {dataToShow.length > 0 && ( + setExpanded(value => !value)} + > + {!expanded && dataToShow.length < selectedCategories.length + ? `+${selectedCategories.length - dataToShow.length}` + : ''} + + + + + )} + + +
+ ); +} + +const Block = styled.div``; + +const Header = styled.div` + h3 { + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-bottom: 4px; + font-weight: 350; + } + + p { + font-size: 14px; + color: ${({ theme }) => theme.colors.gray}; + margin-bottom: 16px; + font-weight: 350; + } +`; + +const Wrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const Grid = styled.div` + gap: 10px; + display: flex; + flex-wrap: wrap; +`; + +const CategoryButton = styled.button<{ $active: boolean; $loading: boolean }>` + position: relative; + overflow: hidden; + height: 32px; + padding: 8px 16px; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: background-color 1.2s; + background-color: ${({ $active }) => ($active ? '#0C3B2E' : '#F1FAF0')}; + color: ${({ $active }) => ($active ? '#F1FAF0' : '#0C3B2E')}; + + &:hover { + background-color: ${({ $active }) => ($active ? '#0C3B2E' : '#e4f0de')}; + } + + ${({ $loading }) => $loading && ` + color: #0C3B2E; + + &:before, + &:after { + content: ''; + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + position: absolute; + inset: 0; + } + + &:before { + background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%; + animation: opacityAnim 1s ease-in-out 0s infinite alternate; + } + + &:after { + background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%; + animation: opacityAnim 1s ease-in-out -1s infinite alternate; + } + + span { + position: relative; + z-index: 2; + } + `} +`; + +const ToggleButton = styled.button<{ $collapsed: boolean }>` + height: 32px; + min-width: 32px; + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + background-color: #f2f9ef; + border: none; + border-radius: 8px; + cursor: pointer; + transition: 0.2s; + padding: 6px 8px 6px 16px; + color: ${({ theme }) => theme.colors.darkGreen}; + font-size: 14px; + font-weight: 350; + + &:hover { + background-color: #e4f0de; + } + + svg { + width: 18px; + height: 18px; + stroke: ${({ theme }) => theme.colors.darkGreen}; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 0.3s ease; + transform: ${({ $collapsed }) => ($collapsed ? 'rotate(180deg)' : 'none')}; + } +`; \ No newline at end of file diff --git a/src/components/CookiePopup/CookiePopup.tsx b/src/components/CookiePopup/CookiePopup.tsx new file mode 100644 index 0000000..43d3bfa --- /dev/null +++ b/src/components/CookiePopup/CookiePopup.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; + +export function CookiePopup() { + const [visible, setVisible] = useState(false); + const [hiding, setHiding] = useState(false); + + useEffect(() => { + if (!localStorage.getItem('cookie_accepted')) { + const timerId = window.setTimeout(() => { + setVisible(true); + }, 500); + + return () => window.clearTimeout(timerId); + } + }, []); + + if (!visible) return null; + + function accept() { + localStorage.setItem('cookie_accepted', 'true'); + setHiding(true); + + window.setTimeout(() => { + setVisible(false); + }, 400); + } + + return ( + + + Cookie icon + + + + Мы используем{' '} + + куки + {' '} + и{' '} + + рекомендательные технологии + + , это помогает улучшать сервис и запоминать ваши настройки + + + + Хорошо + + + ); +} + +const Popup = styled.div<{ $hiding: boolean }>` + position: fixed; + bottom: 25px; + left: 50%; + transform: translateX(-50%) translateY(${({ $hiding }) => ($hiding ? '30px' : '0')}); + background: #ffffff; + border-radius: 20px; + border: 2px solid #E7EDE7; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05), 0 4px 10px rgba(0, 0, 0, 0.02); + padding: 16px 32px; + display: flex; + align-items: center; + gap: 20px; + width: 95%; + max-width: 780px; + z-index: 9999; + opacity: ${({ $hiding }) => ($hiding ? 0 : 1)}; + visibility: ${({ $hiding }) => ($hiding ? 'hidden' : 'visible')}; + transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + + @media (max-width: 600px) { + flex-wrap: wrap; + justify-content: center; + padding: 20px; + bottom: 15px; + text-align: center; + border-radius: 16px; + gap: 12px; + } +`; + +const IconWrapper = styled.div` + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 50%; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #edf2f7; + box-shadow: inset 0 4px 10px 0 rgba(12, 59, 46, 0.1); + overflow: hidden; + + img { + width: 85%; + height: 85%; + object-fit: contain; + } + + @media (max-width: 600px) { + width: 50px; + height: 50px; + } +`; + +const Text = styled.p` + margin: 0; + flex-grow: 1; + color: ${({ theme }) => theme.colors.darkGreen}; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.01em; + + a { + color: #69a342; + font-weight: 600; + } + + @media (max-width: 600px) { + font-size: 14px; + width: 100%; + order: 2; + } +`; + +const AcceptButton = styled.button` + flex-shrink: 0; + background-color: #d6e6d4; + color: #1c3a2e; + border: none; + padding: 10px 24px; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + white-space: nowrap; + + &:hover { + background-color: #c2d6c0; + } + + &:active { + transform: scale(0.96); + } + + @media (max-width: 600px) { + width: 100%; + justify-content: center; + padding: 12px; + order: 3; + } +`; \ No newline at end of file diff --git a/src/components/Days/DaysSection.tsx b/src/components/Days/DaysSection.tsx new file mode 100644 index 0000000..07e7479 --- /dev/null +++ b/src/components/Days/DaysSection.tsx @@ -0,0 +1,53 @@ +import styled from 'styled-components'; + +interface DaysSectionProps { + value: number; + onChange: (value: number) => void; +} + +const options = [1, 3, 7]; + +export function DaysSection({ value, onChange }: DaysSectionProps) { + return ( + + {options.map(option => ( + onChange(option)} + > + {option} + + ))} + + ); +} + +const DaysOptions = styled.div` + display: flex; + gap: 12px; +`; + +const DayButton = styled.button<{ $active: boolean }>` + flex: 1; + max-width: 140px; + height: 52px; + border: 2px solid ${({ $active }) => ($active ? '#5EA12D' : '#e1e1e1')}; + border-radius: 8px; + background: ${({ $active }) => ($active ? '#E6F696' : '#ffffff')}; + font-size: 16px; + line-height: 19px; + font-weight: 350; + color: ${({ theme }) => theme.colors.darkGreen}; + cursor: pointer; + transition: 0.2s; + + &:hover { + border-color: ${({ theme }) => theme.colors.green}; + } + + &:focus { + outline: none; + } +`; \ No newline at end of file diff --git a/src/components/Family/FamilySection.tsx b/src/components/Family/FamilySection.tsx new file mode 100644 index 0000000..bdb96d2 --- /dev/null +++ b/src/components/Family/FamilySection.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import { declension } from '../../utils/declension'; +import type { User } from '../../types/user'; +import { PersonModal } from './PersonModal'; + +interface FamilySectionProps { + users: User[]; + availableTags: string[]; + onChange: (users: User[]) => void; +} + +export function FamilySection({ + users, + availableTags, + onChange, + }: FamilySectionProps) { + const [editingIndex, setEditingIndex] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + function handleDelete(index: number) { + onChange(users.filter((_, userIndex) => userIndex !== index)); + } + + function handleSave(user: User) { + if (editingIndex === null) { + onChange([...users, user]); + } else { + onChange(users.map((item, index) => index === editingIndex ? user : item)); + } + + setIsModalOpen(false); + setEditingIndex(null); + } + + return ( + <> + + {users.map((user, index) => ( + { + setEditingIndex(index); + setIsModalOpen(true); + }} + > + + + + + + {user.name} + {user.age} {declension(user.age, ['год', 'года', 'лет'])} + + + { + event.stopPropagation(); + handleDelete(index); + }} + > + × + + + ))} + + + + { + setEditingIndex(null); + setIsModalOpen(true); + }} + > + Добавить человека + + + + + { + setIsModalOpen(false); + setEditingIndex(null); + }} + onSave={handleSave} + /> + + ); +} + +function getAvatarSrc(user: User): string { + if (user.age < 16) { + return user.gender === 'f' + ? '/images/broc-girl.png' + : '/images/broc-boy.png'; + } + + return user.gender === 'f' + ? '/images/broc-woman.png' + : '/images/broc-man.png'; +} + +const FamilyList = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const FamilyMember = styled.button` + width: 100%; + display: flex; + align-items: center; + padding: 12px 16px; + border: 1px solid ${({ theme }) => theme.colors.border}; + border-radius: 12px; + background: #fff; + transition: 0.2s; + cursor: pointer; + text-align: left; + + &:hover { + border-color: #c0c0c0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); + } + + &:nth-child(n+1) ${() => Avatar} { + background-color: #B4AAFF; + } + + &:nth-child(n+2) ${() => Avatar} { + background-color: #CEE0FF; + } + + &:nth-child(n+3) ${() => Avatar} { + background-color: #E6F696; + } + + &:nth-child(n+4) ${() => Avatar} { + background-color: #FFF488; + } + + &:nth-child(n+5) ${() => Avatar} { + background-color: #FFD623; + } +`; + +const Avatar = styled.div` + width: 46px; + height: 46px; + border-radius: 50%; + background: #f8f9fa; + display: flex; + align-items: center; + justify-content: center; + margin-right: 14px; + overflow: hidden; + + img { + max-width: 100%; + } +`; + +const Info = styled.div` + flex: 1; + display: flex; + flex-direction: column; +`; + +const Name = styled.div` + font-size: 16px; + font-weight: 600; + color: ${({ theme }) => theme.colors.darkGreen}; +`; + +const Age = styled.div` + font-size: 14px; + color: ${({ theme }) => theme.colors.gray}; +`; + +const DeleteButton = styled.button` + color: #d1d5db; + cursor: pointer; + font-size: 18px; + padding: 0 8px; + transition: 0.2s; + background: transparent; + border: 0; + + &:hover { + color: #e53935; + } +`; + +const AddWrapper = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; +`; + +const AddButton = styled.button` + padding: 18px 23px; + border-radius: 12px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 16px; + color: ${({ theme }) => theme.colors.green}; + font-size: 15px; + font-weight: 350; + cursor: pointer; + transition: 0.2s; + background-color: transparent; + border: 0; + + &:hover { + background-color: #F7FBF6; + } + + &:active, + &:focus { + background-color: #EEF8EB; + outline: none; + } + + span { + font-size: 20px; + margin-left: 8px; + font-weight: 300; + } +`; \ No newline at end of file diff --git a/src/components/Family/PersonModal.tsx b/src/components/Family/PersonModal.tsx new file mode 100644 index 0000000..11ae6ad --- /dev/null +++ b/src/components/Family/PersonModal.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import type { Gender, User } from '../../types/user'; +import { TagsInput } from './TagsInput'; + +interface PersonModalProps { + isOpen: boolean; + user: User | null; + availableTags: string[]; + onClose: () => void; + onSave: (user: User) => void; +} + +interface PersonForm { + name: string; + gender: Gender; + age: string; + weight: string; + height: string; + avoid: string[]; + favorite: string[]; +} + +const defaultForm: PersonForm = { + name: '', + gender: 'm', + age: '', + weight: '', + height: '', + avoid: [], + favorite: [], +}; + +export function PersonModal({ + isOpen, + user, + availableTags, + onClose, + onSave, + }: PersonModalProps) { + const [form, setForm] = useState(defaultForm); + + useEffect(() => { + if (!isOpen) return; + + if (user) { + setForm({ + name: user.name, + gender: user.gender, + age: String(user.age), + weight: user.weight ? String(user.weight) : '', + height: user.height ? String(user.height) : '', + avoid: user.avoid ?? [], + favorite: user.favorite ?? [], + }); + } else { + setForm(defaultForm); + } + }, [isOpen, user]); + + if (!isOpen) return null; + + function updateForm(name: TName, value: PersonForm[TName]) { + setForm(current => ({ + ...current, + [name]: value, + })); + } + + function handleAvoidChange(tags: string[]) { + setForm(current => ({ + ...current, + avoid: tags, + favorite: current.favorite.filter(tag => !tags.includes(tag)), + })); + } + + function handleFavoriteChange(tags: string[]) { + setForm(current => ({ + ...current, + favorite: tags, + avoid: current.avoid.filter(tag => !tags.includes(tag)), + })); + } + + function handleSave() { + const age = Number(form.age); + + if (!form.name.trim()) { + alert('Пожалуйста, введите имя.'); + return; + } + + if (!age || age < 7) { + alert('Возраст должен быть от 7 лет.'); + return; + } + + onSave({ + id: user?.id, + name: form.name.trim(), + gender: form.gender, + age, + weight: form.weight ? Number(form.weight) : undefined, + height: form.height ? Number(form.height) : undefined, + avoid: form.avoid, + favorite: form.favorite, + }); + } + + return ( + + event.stopPropagation()}> +
+

{user ? 'Редактировать человека' : 'Добавить человека'}

+ + × + +
+ + + + updateForm('name', event.target.value)} + /> + + + + + + updateForm('gender', 'f')} + > + Женский + + updateForm('gender', 'm')} + > + Мужской + + + + + + + + updateForm('age', event.target.value)} + /> + от 7 лет + + + + + updateForm('weight', event.target.value)} + /> + + + + + updateForm('height', event.target.value)} + /> + + + + + + + + + + + Сохранить + +
+
+ ); +} + +const Overlay = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +`; + +const Content = styled.div` + background: #ffffff; + border-radius: 24px; + width: 100%; + max-width: 440px; + padding: 24px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + max-height: 95vh; + overflow-y: auto; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + h2 { + font-size: 20px; + font-weight: 600; + color: ${({ theme }) => theme.colors.darkGreen}; + } +`; + +const CloseButton = styled.button` + font-size: 24px; + color: #b0b0b0; + cursor: pointer; + line-height: 1; + background: transparent; + border: 0; + + &:hover { + color: ${({ theme }) => theme.colors.darkGreen}; + } +`; + +const FormGroup = styled.div` + margin-bottom: 16px; + flex: 1; + + label { + display: block; + font-size: 14px; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-bottom: 6px; + } + + input { + width: 100%; + padding: 10px 12px; + border: 1px solid #e1e1e1; + border-radius: 8px; + font-size: 14px; + outline: none; + transition: 0.2s; + + &:focus { + border-color: ${({ theme }) => theme.colors.green}; + } + } +`; + +const FormRow = styled.div` + display: flex; + gap: 16px; + margin-bottom: 16px; + + ${FormGroup} { + margin-bottom: 0; + } + + @media (max-width: 600px) { + flex-direction: column; + } +`; + +const Hint = styled.span` + display: block; + font-size: 12px; + color: ${({ theme }) => theme.colors.gray}; + margin-top: 4px; +`; + +const GenderToggles = styled.div` + display: flex; + gap: 8px; + margin-bottom: 16px; +`; + +const GenderButton = styled.button<{ $active: boolean }>` + flex: 1; + padding: 10px; + border: 1px solid ${({ $active }) => ($active ? '#e9f6d9' : '#e1e1e1')}; + border-radius: 8px; + background: ${({ $active }) => ($active ? '#e9f6d9' : '#ffffff')}; + font-size: 14px; + cursor: pointer; + transition: 0.2s; + font-weight: ${({ $active }) => ($active ? 500 : 400)}; +`; + +const PrefsGrid = styled.div` + display: flex; + gap: 16px; + margin-bottom: 20px; + border-top: 1px solid #f0f0f0; + padding-top: 16px; + + @media (max-width: 600px) { + flex-direction: column; + } +`; + +const SaveButton = styled.button` + width: 100%; + padding: 12px; + background: ${({ theme }) => theme.colors.green}; + color: #fff; + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: 0.2s; + + &:hover { + background: #5b8f29; + } +`; \ No newline at end of file diff --git a/src/components/Family/TagsInput.tsx b/src/components/Family/TagsInput.tsx new file mode 100644 index 0000000..4c6cb58 --- /dev/null +++ b/src/components/Family/TagsInput.tsx @@ -0,0 +1,169 @@ +import { useMemo, useState } from 'react'; +import styled from 'styled-components'; + +interface TagsInputProps { + title: string; + type: 'avoid' | 'favorite'; + value: string[]; + availableTags: string[]; + excludedTags: string[]; + onChange: (tags: string[]) => void; +} + +export function TagsInput({ + title, + type, + value, + availableTags, + excludedTags, + onChange, + }: TagsInputProps) { + const [query, setQuery] = useState(''); + + const matches = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + + if (!normalizedQuery) return []; + + const selected = new Set([...value, ...excludedTags]); + + return availableTags.filter(tag => + tag.toLowerCase().includes(normalizedQuery) && !selected.has(tag), + ); + }, [availableTags, excludedTags, query, value]); + + function addTag(tag: string) { + if (value.includes(tag)) return; + + onChange([...value, tag]); + setQuery(''); + } + + function removeTag(tag: string) { + onChange(value.filter(item => item !== tag)); + } + + return ( + + {title} + + setQuery(event.target.value)} + /> + + {matches.length > 0 && ( + + {matches.map(match => ( + + ))} + + )} + + + {value.map(tag => ( + + {tag} + + + ))} + + + ); +} + +const Column = styled.div` + flex: 1; + position: relative; +`; + +const Title = styled.span` + display: block; + font-size: 14px; + font-weight: 600; + color: ${({ theme }) => theme.colors.darkGreen}; + margin-bottom: 6px; +`; + +const Input = styled.input` + width: 100%; + padding: 8px 10px; + border: 1px solid #e1e1e1; + border-radius: 8px; + font-size: 14px; + outline: none; + margin-bottom: 6px; + + &:focus { + border-color: ${({ theme }) => theme.colors.green}; + } +`; + +const Autocomplete = styled.div` + position: absolute; + top: 65px; + left: 0; + width: 100%; + background: #fff; + border: 1px solid #e1e1e1; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); + max-height: 120px; + overflow-y: auto; + z-index: 20; + + button { + width: 100%; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + transition: 0.2s; + background: transparent; + border: 0; + text-align: left; + + &:hover { + background: #f3f4f6; + } + } +`; + +const TagsWrap = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; +`; + +const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>` + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 6px; + font-size: 13px; + background-color: ${({ $type }) => ($type === 'avoid' ? '#fce4ec' : '#f0f4c3')}; + color: ${({ $type }) => ($type === 'avoid' ? '#c62828' : '#33691e')}; + + button { + margin-left: 6px; + cursor: pointer; + font-size: 15px; + opacity: 0.6; + border: 0; + background: transparent; + color: inherit; + + &:hover { + opacity: 1; + } + } +`; \ No newline at end of file diff --git a/src/components/Icons/ArrowIcon.tsx b/src/components/Icons/ArrowIcon.tsx new file mode 100644 index 0000000..e8e0485 --- /dev/null +++ b/src/components/Icons/ArrowIcon.tsx @@ -0,0 +1,13 @@ +export function ArrowIcon() { + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/Icons/BagIcon.tsx b/src/components/Icons/BagIcon.tsx new file mode 100644 index 0000000..2533a7f --- /dev/null +++ b/src/components/Icons/BagIcon.tsx @@ -0,0 +1,9 @@ +export function BagIcon() { + return + + + + + + ; +} \ No newline at end of file diff --git a/src/components/Icons/HeartIcon.tsx b/src/components/Icons/HeartIcon.tsx new file mode 100644 index 0000000..e6bdc98 --- /dev/null +++ b/src/components/Icons/HeartIcon.tsx @@ -0,0 +1,11 @@ +export function HeartIcon() { + return ( + + + + + ); +} \ No newline at end of file diff --git a/src/components/components/Icons/BagIcon.tsx b/src/components/components/Icons/BagIcon.tsx new file mode 100644 index 0000000..76c9a5a --- /dev/null +++ b/src/components/components/Icons/BagIcon.tsx @@ -0,0 +1,20 @@ +export function BagIcon() { + return ( + + + + + + + + ); +} \ No newline at end of file diff --git a/src/hooks/useCartPolling.ts b/src/hooks/useCartPolling.ts new file mode 100644 index 0000000..7082c81 --- /dev/null +++ b/src/hooks/useCartPolling.ts @@ -0,0 +1,92 @@ +import {Dispatch, SetStateAction, useEffect, useRef} from 'react'; +import { getCartUpdates } from '../api/promoApi'; +import { normalizeProductImage } from '../utils/product'; +import type { Product } from '../types/cart'; + +interface UseCartPollingParams { + enabled: boolean; + onProductsChange: Dispatch>; + onComplete: () => void; + onLoadingChange: (isLoading: boolean) => void; + setDisplayText: Dispatch>; +} + +export function useCartPolling({ + enabled, + onProductsChange, + onComplete, + onLoadingChange, + setDisplayText, + }: UseCartPollingParams) { + const timeoutRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + + async function tick() { + try { + onLoadingChange(true); + + const update = await getCartUpdates(); + + if (cancelled) return; + + if (update.status === 'complete') { + onLoadingChange(false); + onComplete(); + return; + } + if (update.status === 'waiting') { + setDisplayText('Брок бегает по супермаркету...') + return; + } + + if (update.status === 'clear') { + const nextProducts = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? []; + onProductsChange(nextProducts); + } + + if (update.status === 'data') { + const chunk = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? []; + + onProductsChange(current => { + const next = [...current]; + + chunk.forEach(product => { + const index = next.findIndex(item => item.name === product.name); + + if (index === -1) { + next.push(product); + } else { + next[index] = { + ...next[index], + n: next[index].n + product.n, + }; + } + }); + + return next; + }); + + setDisplayText('Брок закинул пару товаров...') + } + timeoutRef.current = window.setTimeout(tick, 3000); + } catch (error) { + setDisplayText('Ищем Брока...') + timeoutRef.current = window.setTimeout(tick, 3000); + } + } + + tick(); + + return () => { + cancelled = true; + + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, [enabled, onComplete, onLoadingChange, onProductsChange]); +} \ No newline at end of file diff --git a/src/hooks/useDebounceBatch.ts b/src/hooks/useDebounceBatch.ts new file mode 100644 index 0000000..0df899e --- /dev/null +++ b/src/hooks/useDebounceBatch.ts @@ -0,0 +1,53 @@ +import { useMemo, useRef } from 'react'; + +interface Batch { + ids: T[]; + resolves: Array<(value: unknown) => void>; + rejects: Array<(reason?: unknown) => void>; +} + +export function useDebounceBatch( + handler: (items: T[]) => Promise, + delay: number, +) { + const pendingRef = useRef | null>(null); + const timerRef = useRef(null); + + return useMemo(() => { + return (item: T) => { + return new Promise((resolve, reject) => { + if (!pendingRef.current) { + pendingRef.current = { + ids: [], + resolves: [], + rejects: [], + }; + } + + pendingRef.current.ids.push(item); + pendingRef.current.resolves.push(resolve); + pendingRef.current.rejects.push(reject); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(async () => { + const batch = pendingRef.current; + + pendingRef.current = null; + timerRef.current = null; + + if (!batch) return; + + try { + const result = await handler(batch.ids); + batch.resolves.forEach(resolveBatch => resolveBatch(result)); + } catch (error) { + batch.rejects.forEach(rejectBatch => rejectBatch(error)); + } + }, delay); + }); + }; + }, [delay, handler]); +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..cc3bcd4 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeProvider } from 'styled-components'; +import { App } from './App'; +import { GlobalStyle } from './styles/GlobalStyle'; +import { theme } from './styles/theme'; + +const root = document.getElementById('root'); + +if (!root) { + throw new Error('Root element #root was not found'); +} + +createRoot(root).render( + + + + + + , +); \ No newline at end of file diff --git a/src/styled-components.d.ts b/src/styled-components.d.ts new file mode 100644 index 0000000..343eda8 --- /dev/null +++ b/src/styled-components.d.ts @@ -0,0 +1,17 @@ +import type { CSSProp } from "styled-components"; +import Theme from './theme'; + +type ThemeType = typeof Theme; + +declare module "styled-components" { + export interface DefaultTheme extends ThemeType { + colors: ThemeType['colors']; + radius: ThemeType['radius']; + } +} + +declare module "react" { + interface DOMAttributes { + css?: CSSProp; + } +} \ No newline at end of file diff --git a/src/styles/GlobalStyle.ts b/src/styles/GlobalStyle.ts new file mode 100644 index 0000000..b036183 --- /dev/null +++ b/src/styles/GlobalStyle.ts @@ -0,0 +1,55 @@ +import { createGlobalStyle } from 'styled-components'; + +export const GlobalStyle = createGlobalStyle` + @font-face { + font-family: 'FallbackFont'; + src: local('Helvetica'); + size-adjust: 98.5%; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Roboto', 'FallbackFont', system-ui, -apple-system, 'Segoe UI', Helvetica, sans-serif; + } + + html { + overflow: hidden; + } + + body { + background-color: ${({ theme }) => theme.colors.pageBg}; + background-image: url('/images/background-raspberry.png'); + background-repeat: no-repeat; + background-attachment: fixed; + background-size: contain; + background-position: left top; + min-height: 100vh; + } + + button { + -webkit-touch-callout: none; + user-select: none; + } + + @keyframes opacityAnim { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } +`; \ No newline at end of file diff --git a/src/styles/theme.ts b/src/styles/theme.ts new file mode 100644 index 0000000..66c01b3 --- /dev/null +++ b/src/styles/theme.ts @@ -0,0 +1,19 @@ +export const theme = { + colors: { + darkGreen: '#0C3B2E', + green: '#5EA12D', + greenHover: '#72B541', + lightGreen: '#E6F696', + paleGreen: '#F1FAF0', + gray: '#A3ADA2', + border: '#ebebeb', + white: '#ffffff', + pageBg: '#FF7D89', + }, + radius: { + page: '24px', + card: '16px', + button: '12px', + small: '8px', + }, +}; \ No newline at end of file diff --git a/src/template.html b/src/template.html new file mode 100644 index 0000000..0d5e71f --- /dev/null +++ b/src/template.html @@ -0,0 +1,33 @@ + + + + + + Автокорзина + + + + + + + + +
+ + + \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..1b8b7f1 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,25 @@ +import type { Product } from './cart'; +import type { User } from './user'; + +export interface InitialData { + existing?: number; + available_groups: string[]; + initial_groups: string[]; + days: number; + users: User[]; + cart: Product[][]; + excess?: number; + precision?: number; +} + +export type CartUpdateStatus = 'clear' | 'data' | 'waiting' | 'complete'; + +export interface CartUpdateResponse { + status: CartUpdateStatus; + error_message?: string; + payload?: { + excess?: number; + precision?: number; + chunk?: Product[]; + }; +} \ No newline at end of file diff --git a/src/types/cart.ts b/src/types/cart.ts new file mode 100644 index 0000000..682c0c3 --- /dev/null +++ b/src/types/cart.ts @@ -0,0 +1,11 @@ +export type ProductUnit = 'g' | 'pcs'; + +export interface Product { + name: string; + n: number; + price: number; + total_price: number; + currency: string; + image?: string; + unit: ProductUnit; +} \ No newline at end of file diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..9b7a7fc --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,12 @@ +export type Gender = 'm' | 'f'; + +export interface User { + id?: number; + name: string; + gender: Gender; + age: number; + weight?: number; + height?: number; + avoid: string[]; + favorite: string[]; +} \ No newline at end of file diff --git a/src/utils/declension.ts b/src/utils/declension.ts new file mode 100644 index 0000000..9761016 --- /dev/null +++ b/src/utils/declension.ts @@ -0,0 +1,9 @@ +export function declension(number: number, titles: [string, string, string]): string { + const cases = [2, 0, 1, 1, 1, 2]; + + const idx = number % 100 > 4 && number % 100 < 20 + ? 2 + : cases[Math.min(number % 10, 5)]; + + return titles[idx]; +} \ No newline at end of file diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts new file mode 100644 index 0000000..36162ac --- /dev/null +++ b/src/utils/formatCurrency.ts @@ -0,0 +1,6 @@ +export function formatCurrency(value: number): string { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + }).format(Math.round(value * 100) / 100); +} \ No newline at end of file diff --git a/src/utils/product.ts b/src/utils/product.ts new file mode 100644 index 0000000..aad0138 --- /dev/null +++ b/src/utils/product.ts @@ -0,0 +1,18 @@ +import type { Product } from '../types/cart'; + +export function normalizeProductImage(product: Product, size = '65,fit'): Product { + return { + ...product, + image: product.image?.replace('{SIZE}', size), + }; +} + +export function getLargeProductImage(product: Product): string { + return product.image?.replace('65,fit', '335,fit') ?? ''; +} + +export function getProductAmount(product: Product): string { + return product.unit === 'g' + ? `${product.n} г` + : `${product.n} шт`; +} \ No newline at end of file diff --git a/src/auto_cart.html b/src_static/auto_cart.html similarity index 100% rename from src/auto_cart.html rename to src_static/auto_cart.html diff --git a/src/autocart.js b/src_static/autocart.js similarity index 100% rename from src/autocart.js rename to src_static/autocart.js diff --git a/src/cookies_and_recommendations.html b/src_static/cookies_and_recommendations.html similarity index 100% rename from src/cookies_and_recommendations.html rename to src_static/cookies_and_recommendations.html diff --git a/src/index.html b/src_static/index.html similarity index 100% rename from src/index.html rename to src_static/index.html diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bf1d4f0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..20334bb --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import fs from 'node:fs'; + +export default defineConfig({ + plugins: [react()], + server: { + host: 'local.hvatilo.ru', + port: 9876, + https: { + cert: fs.readFileSync('local.hvatilo.ru.pem'), + key: fs.readFileSync('local.hvatilo.ru-key.pem'), + }, + open: '/src/template.html', + }, + input: { + main: './src/template.html', + }, + build: { + outDir: 'dist', + }, +}); \ No newline at end of file