From 7d97169076e2626834ec2cc5420c80f55f2ff57b Mon Sep 17 00:00:00 2001 From: sad1dab Date: Wed, 18 Feb 2026 21:28:27 +0200 Subject: [PATCH 001/369] feat(react): Create react-ts-vite project --- .gitignore | 362 ++++ frontend/.gitignore | 24 + frontend/README.md | 73 + frontend/eslint.config.js | 23 + frontend/index.html | 13 + frontend/package-lock.json | 3270 +++++++++++++++++++++++++++++++++ frontend/package.json | 30 + frontend/public/vite.svg | 1 + frontend/src/App.css | 42 + frontend/src/App.tsx | 35 + frontend/src/assets/react.svg | 1 + frontend/src/index.css | 68 + frontend/src/main.tsx | 10 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 7 + 17 files changed, 4020 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c7fde --- /dev/null +++ b/.gitignore @@ -0,0 +1,362 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..072a57e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..630dcc8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3270 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..991c982 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3d7ded3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+ + Vite logo + + + React logo + +
+

Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..08a3ac9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From 9e55049f945999d58cf25d4a921b301584f33547 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Wed, 18 Feb 2026 19:58:16 +0000 Subject: [PATCH 002/369] created minimal backend architecture --- backend/app/main.py | 0 backend/app/models/__init__.py | 0 backend/app/routes/__init__.py | 0 backend/app/shemas/__init__.py | 0 requirements.txt | 42 ++++++++++++++++++++++++++++++++++ 5 files changed, 42 insertions(+) create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/routes/__init__.py create mode 100644 backend/app/shemas/__init__.py create mode 100644 requirements.txt diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/shemas/__init__.py b/backend/app/shemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b620da --- /dev/null +++ b/requirements.txt @@ -0,0 +1,42 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +certifi==2026.1.4 +click==8.3.1 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.129.0 +fastapi-cli==0.0.23 +fastapi-cloud-cli==0.13.0 +fastar==0.8.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +Jinja2==3.1.6 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +pydantic==2.12.5 +pydantic-extra-types==2.11.0 +pydantic-settings==2.13.0 +pydantic_core==2.41.5 +Pygments==2.19.2 +python-dotenv==1.2.1 +python-multipart==0.0.22 +PyYAML==6.0.3 +rich==14.3.2 +rich-toolkit==0.19.4 +rignore==0.7.6 +sentry-sdk==2.53.0 +shellingham==1.5.4 +starlette==0.52.1 +typer==0.24.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==16.0 From ba603e55ad1d1a9afabdd8e4d57f0224c93222cc Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 19 Feb 2026 16:32:21 +0200 Subject: [PATCH 003/369] feat: add database --- .gitignore | 3 ++- backend/app/__init__.py | 0 backend/app/config.py | 11 +++++++++++ backend/app/db.py | 10 ++++++++++ backend/app/init_db.py | 8 ++++++++ backend/app/main.py | 3 +++ backend/app/models/__init__.py | 1 + backend/app/models/base.py | 5 +++++ 8 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/init_db.py create mode 100644 backend/app/models/base.py diff --git a/.gitignore b/.gitignore index 41c7fde..6b6b3ed 100644 --- a/.gitignore +++ b/.gitignore @@ -359,4 +359,5 @@ dist # Vite files vite.config.js.timestamp-* vite.config.ts.timestamp-* -.vite/ \ No newline at end of file +.vite/ +*.db \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..ffce34d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +import os + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file='.env' + ) + SECRET_KEY: str = os.getenv('SECRET_KEY') + SQLALCHEMY_DATABASE_URI: str = os.getenv('SQLALCHEMY_DATABASE_URI') + +settings = Settings() \ No newline at end of file diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..2cf72d1 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,10 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session, Session +from app.config import settings + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +Sess = scoped_session(sessionmaker(bind=engine)) + +def get_session() -> Session: + with Sess() as sess: + return sess \ No newline at end of file diff --git a/backend/app/init_db.py b/backend/app/init_db.py new file mode 100644 index 0000000..40cba1c --- /dev/null +++ b/backend/app/init_db.py @@ -0,0 +1,8 @@ +from app.models import Base +from app.db import engine + +def init_db() -> None: + Base.metadata.create_all(bind=engine) + +if __name__ == '__main__': + init_db() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index e69de29..9bb71ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -0,0 +1,3 @@ +from fastapi import FastAPI + +app = FastAPI() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29..c536f9c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +from .base import Base \ No newline at end of file diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..d4c131f --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass + From 63096e1e7595afb3a1d5aca3e11d7e780f21516b Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 19 Feb 2026 18:26:23 +0000 Subject: [PATCH 004/369] rewrote to async engine, added sqlalchemy to requirements.txt, corrected folder name, add .env.example --- .env.example | 2 ++ backend/app/config.py | 12 ++++++------ backend/app/db.py | 14 +++++++------- backend/app/init_db.py | 12 ++++++++---- backend/app/main.py | 1 + backend/app/{shemas => schemas}/__init__.py | 0 requirements.txt | 2 ++ 7 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 .env.example rename backend/app/{shemas => schemas}/__init__.py (100%) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf055f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +SECRET_KEY="your secret key" +SQLALCHEMY_DATABASE_URI="your database url" diff --git a/backend/app/config.py b/backend/app/config.py index ffce34d..e96cfeb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,11 +1,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict import os + class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_file='.env' - ) - SECRET_KEY: str = os.getenv('SECRET_KEY') - SQLALCHEMY_DATABASE_URI: str = os.getenv('SQLALCHEMY_DATABASE_URI') + model_config = SettingsConfigDict(env_file=".env") + SECRET_KEY: str = os.getenv("SECRET_KEY") + SQLALCHEMY_DATABASE_URI: str = os.getenv("SQLALCHEMY_DATABASE_URI") + -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/backend/app/db.py b/backend/app/db.py index 2cf72d1..0af2148 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,10 +1,10 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, scoped_session, Session +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from app.config import settings -engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) -Sess = scoped_session(sessionmaker(bind=engine)) +engine = create_async_engine(settings.SQLALCHEMY_DATABASE_URI, echo=True) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) -def get_session() -> Session: - with Sess() as sess: - return sess \ No newline at end of file + +async def get_session() -> AsyncSession: + async with AsyncSessionLocal() as sess: + yield sess diff --git a/backend/app/init_db.py b/backend/app/init_db.py index 40cba1c..47356dc 100644 --- a/backend/app/init_db.py +++ b/backend/app/init_db.py @@ -1,8 +1,12 @@ +import asyncio from app.models import Base from app.db import engine -def init_db() -> None: - Base.metadata.create_all(bind=engine) -if __name__ == '__main__': - init_db() \ No newline at end of file +async def init_db() -> None: + async with engine.connect() as conn: + await conn.run_sync(Base.metadata.create_all) + + +if __name__ == "__main__": + asyncio.run(init_db()) diff --git a/backend/app/main.py b/backend/app/main.py index 9bb71ec..95f3f53 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ from fastapi import FastAPI + app = FastAPI() diff --git a/backend/app/shemas/__init__.py b/backend/app/schemas/__init__.py similarity index 100% rename from backend/app/shemas/__init__.py rename to backend/app/schemas/__init__.py diff --git a/requirements.txt b/requirements.txt index 6b620da..54d2d04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ fastapi==0.129.0 fastapi-cli==0.0.23 fastapi-cloud-cli==0.13.0 fastar==0.8.0 +greenlet==3.3.1 h11==0.16.0 httpcore==1.0.9 httptools==0.7.1 @@ -31,6 +32,7 @@ rich-toolkit==0.19.4 rignore==0.7.6 sentry-sdk==2.53.0 shellingham==1.5.4 +SQLAlchemy==2.0.46 starlette==0.52.1 typer==0.24.0 typing-inspection==0.4.2 From f69a2287116abe3c6f382d71db27fb054e56323e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Thu, 19 Feb 2026 21:05:07 +0200 Subject: [PATCH 005/369] Add greeting to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f495082..c14e511 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# Project \ No newline at end of file +# Project +Hello world! From dfecb7cec93dfaba782d234c9c32800d953dfa1d Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Thu, 19 Feb 2026 21:06:01 +0200 Subject: [PATCH 006/369] Remove greeting from README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index c14e511..dab306f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ # Project -Hello world! From a98313b1db648d05c53757670b4ea813e6181184 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 19 Feb 2026 21:00:06 +0000 Subject: [PATCH 007/369] started writing models(User, Role, Team and TeamMember) add validation for User (not finished), added pkmixin --- backend/app/models/__init__.py | 5 +++- backend/app/models/mixin/__init__.py | 1 + backend/app/models/mixin/pkmixin.py | 5 ++++ backend/app/models/role.py | 16 ++++++++++++ backend/app/models/team.py | 37 ++++++++++++++++++++++++++++ backend/app/models/user.py | 31 +++++++++++++++++++++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/user.py | 12 +++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/mixin/__init__.py create mode 100644 backend/app/models/mixin/pkmixin.py create mode 100644 backend/app/models/role.py create mode 100644 backend/app/models/team.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/user.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c536f9c..2fa97b6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,4 @@ -from .base import Base \ No newline at end of file +from .base import Base +from .role import Role +from .team import Team, TeamMember +from .user import User \ No newline at end of file diff --git a/backend/app/models/mixin/__init__.py b/backend/app/models/mixin/__init__.py new file mode 100644 index 0000000..b110b5c --- /dev/null +++ b/backend/app/models/mixin/__init__.py @@ -0,0 +1 @@ +from .pkmixin import PKMixin diff --git a/backend/app/models/mixin/pkmixin.py b/backend/app/models/mixin/pkmixin.py new file mode 100644 index 0000000..9846933 --- /dev/null +++ b/backend/app/models/mixin/pkmixin.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import Mapped, mapped_column + + +class PKMixin: + id: Mapped[int] = mapped_column(primary_key=True) diff --git a/backend/app/models/role.py b/backend/app/models/role.py new file mode 100644 index 0000000..dd3139d --- /dev/null +++ b/backend/app/models/role.py @@ -0,0 +1,16 @@ +from typing import List +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .mixin import PKMixin +from .user import User, user_roles + + +class Role(Base, PKMixin): + __tablename__ = "roles" + + name: Mapped[str] = mapped_column(default="Team", unique=True) + + users: Mapped[List["User"]] = relationship( + secondary=user_roles, back_populates="roles" + ) diff --git a/backend/app/models/team.py b/backend/app/models/team.py new file mode 100644 index 0000000..ec65e23 --- /dev/null +++ b/backend/app/models/team.py @@ -0,0 +1,37 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .mixin import PKMixin + + +class Team(Base, PKMixin): + __tablename__ = "teams" + + name: Mapped[str] = mapped_column(unique=True) + team_email: Mapped[str] + contact_info: Mapped[str] + tournament_id: Mapped[int] + captain_id: Mapped[int] = mapped_column(ForeignKey("team_members.id")) + + def __repr__(self): + return f"" + + +class TeamMember(Base, PKMixin): + __tablename__ = "team_members" + + full_name: Mapped[str] + email: Mapped[str] = mapped_column(unique=True) + telegram_username: Mapped[str] = mapped_column(unique=True) + educational_institution: Mapped[str] + team_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) + + team: Mapped["Team"] = relationship( + back_populates="members", foreign_keys=[team_id] + ) + + def __repr__(self): + return ( + f"" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..bc31ca1 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,31 @@ +from typing import List +from datetime import datetime +from sqlalchemy import ForeignKey, Table, Column +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .mixin import PKMixin + +user_roles = Table( + "user_roles", + Base.metadata, + Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), +) + + +class User(Base, PKMixin): + __tablename__ = "users" + + full_name: Mapped[str] + email: Mapped[str] = mapped_column(unique=True) + password: Mapped[str] + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False) + cleated_at: Mapped[datetime] = mapped_column(datetime.now) + + roles: Mapped[List["Role"]] = relationship( + secondary=user_roles, back_populates="users" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e69de29..6ad224f 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +from .user import UserModel \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..a4d0e6e --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,12 @@ +from typing import List +from pydantic import BaseModel, Field, EmailStr, field_validator + +from ..models import Role + + +class UserModel(BaseModel): + id: int + full_name: str = Field(..., description="Username") + email: EmailStr = Field(..., description="User email") + password: str = Field(..., min_length=6, description="User password") + role: List[Role] From 8d5f65ed5b381934bb9dfb07b967c6f0ba8dd34e Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 20 Feb 2026 18:27:31 +0000 Subject: [PATCH 008/369] add tournament model --- backend/app/models/tournament.py | 39 ++++++++++++++++++++++++++++++++ backend/app/models/user.py | 1 - backend/app/schemas/user.py | 16 ++++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 backend/app/models/tournament.py diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py new file mode 100644 index 0000000..abd12f1 --- /dev/null +++ b/backend/app/models/tournament.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import List +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .mixin import PKMixin + + +class Tournament(Base, PKMixin): + __tablename__ = "tournaments" + + title: Mapped[str] = mapped_column(nullable=False) + description: Mapped[str] + start_date: Mapped[datetime] + reg_start: Mapped[datetime] + reg_end: Mapped[datetime] + max_team: Mapped[int] + active_task: Mapped[int] + status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) + creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + + status: Mapped["TournamentStatusOption"] = relationship( + back_populates="tournaments" + ) + creator: Mapped["User"] = relationship( + back_populates="created_tournaments" + ) + + def __repr__(self): + return f"" + + +class TournamentStatusOption(Base, PKMixin): + __tablename__ = "tournament_status_options" + + name: Mapped[str] = mapped_column(unique=True) + + tournaments: Mapped[List["Tournament"]] = relationship(back_populates="status") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bc31ca1..2a5be0c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -20,7 +20,6 @@ class User(Base, PKMixin): full_name: Mapped[str] email: Mapped[str] = mapped_column(unique=True) password: Mapped[str] - role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False) cleated_at: Mapped[datetime] = mapped_column(datetime.now) roles: Mapped[List["Role"]] = relationship( diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a4d0e6e..1aac53b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,12 +1,22 @@ from typing import List from pydantic import BaseModel, Field, EmailStr, field_validator -from ..models import Role - class UserModel(BaseModel): id: int full_name: str = Field(..., description="Username") email: EmailStr = Field(..., description="User email") password: str = Field(..., min_length=6, description="User password") - role: List[Role] + role: List[str] + + @field_validator("full_name") + @classmethod + def check_name(cls, value: str): + if not value.strip(): + raise ValueError("The name cannot be empty") + return value + + @field_validator("email") + @classmethod + def check_email(cls, value: str): + return value.lower().strip() From c34723fd9171cdb85b24ec2c43d2b482da00499e Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 20 Feb 2026 20:39:43 +0000 Subject: [PATCH 009/369] small fix --- backend/app/config.py | 2 +- backend/app/models/__init__.py | 3 ++- backend/app/models/user.py | 6 +++--- requirements.txt | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index e96cfeb..b57c0e3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,7 +3,7 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file="../.env") SECRET_KEY: str = os.getenv("SECRET_KEY") SQLALCHEMY_DATABASE_URI: str = os.getenv("SQLALCHEMY_DATABASE_URI") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2fa97b6..fad5026 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from .base import Base from .role import Role from .team import Team, TeamMember -from .user import User \ No newline at end of file +from .user import User +from .tournament import Tournament, TournamentStatusOption diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2a5be0c..abd93f5 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ from typing import List from datetime import datetime -from sqlalchemy import ForeignKey, Table, Column +from sqlalchemy import ForeignKey, Table, Column, func from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base @@ -20,9 +20,9 @@ class User(Base, PKMixin): full_name: Mapped[str] email: Mapped[str] = mapped_column(unique=True) password: Mapped[str] - cleated_at: Mapped[datetime] = mapped_column(datetime.now) + cleated_at: Mapped[datetime] = mapped_column(server_default=func.now()) - roles: Mapped[List["Role"]] = relationship( + roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users" ) diff --git a/requirements.txt b/requirements.txt index 54d2d04..4ed1cc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +aiosqlite==0.22.1 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 From 8fd3c6499dcaee7fc0ceb43ddc2033d31dae6c23 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Fri, 20 Feb 2026 22:46:40 +0200 Subject: [PATCH 010/369] feat: add task and task-related models --- backend/app/models/__init__.py | 1 + backend/app/models/mixin/__init__.py | 1 + backend/app/models/mixin/option_mixin.py | 5 ++++ backend/app/models/task.py | 34 ++++++++++++++++++++++++ backend/app/models/tournament.py | 4 ++- 5 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/mixin/option_mixin.py create mode 100644 backend/app/models/task.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fad5026..9ff4184 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,3 +3,4 @@ from .team import Team, TeamMember from .user import User from .tournament import Tournament, TournamentStatusOption +from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption \ No newline at end of file diff --git a/backend/app/models/mixin/__init__.py b/backend/app/models/mixin/__init__.py index b110b5c..d5f758f 100644 --- a/backend/app/models/mixin/__init__.py +++ b/backend/app/models/mixin/__init__.py @@ -1 +1,2 @@ from .pkmixin import PKMixin +from .option_mixin import OptionMixin \ No newline at end of file diff --git a/backend/app/models/mixin/option_mixin.py b/backend/app/models/mixin/option_mixin.py new file mode 100644 index 0000000..da2aae6 --- /dev/null +++ b/backend/app/models/mixin/option_mixin.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import Mapped, mapped_column + +class OptionMixin: + name: Mapped[str] = mapped_column(primary_key=True) + display_name: Mapped[str] \ No newline at end of file diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..9782dcb --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,34 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey + +from .base import Base +from .mixin import PKMixin, OptionMixin +from datetime import datetime + + +class Task(Base, PKMixin): + __tablename__ = "tasks" + + title: Mapped[str] + description: Mapped[str] + tournament_id: Mapped[int] = mapped_column(ForeignKey('tournaments.id')) + tournament: Mapped['Tournament'] = relationship(back_populates='tasks') + start_time: Mapped[datetime] + end_time: Mapped[datetime] + status_id: Mapped[str] = mapped_column(ForeignKey('task_statuses.name')) + status: Mapped['TaskStatusOption'] = relationship(back_populates='tasks') + +class TaskStatusOption(Base, OptionMixin): + __tablename__ = 'task_statuses' + tasks: Mapped[list['Task']] = relationship(back_populates='status') + +class TaskRequirementOption(Base, OptionMixin): + __tablename__ = 'task_requirement_options' + category_id: Mapped[str] = mapped_column(ForeignKey('task_statuses.name')) + category: Mapped['TaskRequirementCategory'] = relationship() + +class TaskRequirementCategory(Base, OptionMixin): + __tablename__ = 'task_requirement_categories' + category_id: Mapped[str] = mapped_column(ForeignKey('task_requirement_categories.name')) + category: Mapped['TaskRequirementCategory'] = relationship(back_populates='category') + task_requirement_options: Mapped[list['TaskRequirementOption']] = relationship(back_populates='category') diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index abd12f1..1647aa8 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -16,7 +16,9 @@ class Tournament(Base, PKMixin): reg_start: Mapped[datetime] reg_end: Mapped[datetime] max_team: Mapped[int] - active_task: Mapped[int] + active_task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id')) + active_task: Mapped['Task'] = relationship() + tasks: Mapped[list['Task']] = relationship(back_populates='tournament') status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) From ac73b26618ea89c1914f378f733d46f6e02f08b8 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 21 Feb 2026 21:11:21 +0200 Subject: [PATCH 011/369] feat: add submission, submission evaluation and related models --- backend/app/models/evaluation.py | 27 +++++++++++++++++++++++++++ backend/app/models/submission.py | 22 ++++++++++++++++++++++ backend/app/models/task.py | 9 ++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/evaluation.py create mode 100644 backend/app/models/submission.py diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py new file mode 100644 index 0000000..87b469e --- /dev/null +++ b/backend/app/models/evaluation.py @@ -0,0 +1,27 @@ +from sqlalchemy import ForeignKey, Table, Column +from sqlalchemy.orm import mapped_column, Mapped, relationship +from .base import Base + +evaluation_requirements = Table( + "evaluation_requirements", + Base.metadata, + Column("requirement_evaluation_id", ForeignKey("requirement_evaluations.id", ondelete="CASCADE"), primary_key=True), + Column("requirement_id", ForeignKey("task_requirement_options.name", ondelete="CASCADE"), primary_key=True), +) + +class SubmissionEvaluation(Base): + __tablename__ = 'evaluations' + submission_id: Mapped[int] = mapped_column(ForeignKey('submissions.team_id'), primary_key=True) + jury_id: Mapped[int] = mapped_column(ForeignKey('users.id'), primary_key=True) + + submission: Mapped['Submission'] = relationship(back_populates='evaluations') + jury: Mapped['User'] = relationship() + evaluations: Mapped[list['RequirementEvaluation']] = relationship(back_populates='evaluation') + + +class RequirementEvaluation(Base): + __tablename__ = 'requirement_evaluations' + evaluation_id: Mapped[int] = mapped_column(ForeignKey('evaluations.id'), primary_key=True) + evaluation: Mapped['SubmissionEvaluation'] = relationship(back_populates='requirement_evaluations') + score: Mapped[int] + requirement: Mapped['TaskRequirementOption'] = relationship() \ No newline at end of file diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py new file mode 100644 index 0000000..22f9514 --- /dev/null +++ b/backend/app/models/submission.py @@ -0,0 +1,22 @@ +from .base import Base +from .mixin import OptionMixin +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey + +class Submission(Base): + __tablename__ = 'submissions' + team_id: Mapped[int] = mapped_column(ForeignKey('teams.id'), primary_key=True) + team: Mapped['Team'] = relationship(back_populates='submission', single_parent=True) + urls: Mapped[list['SubmissionUrl']] = relationship(back_populates='submission') + +class SubmissionUrl(Base): + __tablename__ = 'submission_urls' + submission_id: Mapped[int] = mapped_column(ForeignKey('submissions.team_id')) + url_id: Mapped[int] = mapped_column(ForeignKey('submission_url_options.name')) + + submission: Mapped['Submission'] = relationship(back_populates='urls') + url: Mapped['SubmissionUrlOption'] = relationship() + +class SubmissionUrlOption(Base, OptionMixin): + __tablename__ = 'submission_url_options' + \ No newline at end of file diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 9782dcb..d7cb35f 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -1,10 +1,16 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import ForeignKey +from sqlalchemy import ForeignKey, Table, Column from .base import Base from .mixin import PKMixin, OptionMixin from datetime import datetime +task_requirements = Table( + 'task_requirements', + Base.metadata, + Column("requirement_id", ForeignKey("task_requirement_options.name", ondelete="CASCADE"), primary_key=True), + Column("task_id", ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), +) class Task(Base, PKMixin): __tablename__ = "tasks" @@ -17,6 +23,7 @@ class Task(Base, PKMixin): end_time: Mapped[datetime] status_id: Mapped[str] = mapped_column(ForeignKey('task_statuses.name')) status: Mapped['TaskStatusOption'] = relationship(back_populates='tasks') + requirements: Mapped[list['TaskRequirementOption']] = relationship(secondary=task_requirements) class TaskStatusOption(Base, OptionMixin): __tablename__ = 'task_statuses' From 45123975011ed2dd346d53f2039708d1188fa221 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 21 Feb 2026 21:48:39 +0000 Subject: [PATCH 012/369] model validation, removed typing List and file formatting (Black Formatter) --- backend/app/models/__init__.py | 2 +- backend/app/models/base.py | 2 +- backend/app/models/evaluation.py | 41 ++++++++++++++------ backend/app/models/mixin/__init__.py | 2 +- backend/app/models/mixin/option_mixin.py | 3 +- backend/app/models/role.py | 5 +-- backend/app/models/submission.py | 24 ++++++------ backend/app/models/task.py | 48 ++++++++++++++++-------- backend/app/models/team.py | 7 +++- backend/app/models/tournament.py | 11 +++--- backend/app/models/user.py | 6 ++- backend/app/schemas/__init__.py | 6 ++- backend/app/schemas/evaluation.py | 10 +++++ backend/app/schemas/task.py | 32 ++++++++++++++++ backend/app/schemas/team.py | 23 ++++++++++++ backend/app/schemas/tournament.py | 37 ++++++++++++++++++ backend/app/schemas/user.py | 4 +- 17 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 backend/app/schemas/evaluation.py create mode 100644 backend/app/schemas/task.py create mode 100644 backend/app/schemas/team.py create mode 100644 backend/app/schemas/tournament.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9ff4184..1becc32 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,4 +3,4 @@ from .team import Team, TeamMember from .user import User from .tournament import Tournament, TournamentStatusOption -from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption \ No newline at end of file +from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption diff --git a/backend/app/models/base.py b/backend/app/models/base.py index d4c131f..fa2b68a 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -1,5 +1,5 @@ from sqlalchemy.orm import DeclarativeBase + class Base(DeclarativeBase): pass - diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index 87b469e..ac3a805 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -5,23 +5,40 @@ evaluation_requirements = Table( "evaluation_requirements", Base.metadata, - Column("requirement_evaluation_id", ForeignKey("requirement_evaluations.id", ondelete="CASCADE"), primary_key=True), - Column("requirement_id", ForeignKey("task_requirement_options.name", ondelete="CASCADE"), primary_key=True), + Column( + "requirement_evaluation_id", + ForeignKey("requirement_evaluations.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "requirement_id", + ForeignKey("task_requirement_options.name", ondelete="CASCADE"), + primary_key=True, + ), ) + class SubmissionEvaluation(Base): - __tablename__ = 'evaluations' - submission_id: Mapped[int] = mapped_column(ForeignKey('submissions.team_id'), primary_key=True) - jury_id: Mapped[int] = mapped_column(ForeignKey('users.id'), primary_key=True) + __tablename__ = "evaluations" + submission_id: Mapped[int] = mapped_column( + ForeignKey("submissions.team_id"), primary_key=True + ) + jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) - submission: Mapped['Submission'] = relationship(back_populates='evaluations') - jury: Mapped['User'] = relationship() - evaluations: Mapped[list['RequirementEvaluation']] = relationship(back_populates='evaluation') + submission: Mapped["Submission"] = relationship(back_populates="evaluations") + jury: Mapped["User"] = relationship() + evaluations: Mapped[list["RequirementEvaluation"]] = relationship( + back_populates="evaluation" + ) class RequirementEvaluation(Base): - __tablename__ = 'requirement_evaluations' - evaluation_id: Mapped[int] = mapped_column(ForeignKey('evaluations.id'), primary_key=True) - evaluation: Mapped['SubmissionEvaluation'] = relationship(back_populates='requirement_evaluations') + __tablename__ = "requirement_evaluations" + evaluation_id: Mapped[int] = mapped_column( + ForeignKey("evaluations.id"), primary_key=True + ) + evaluation: Mapped["SubmissionEvaluation"] = relationship( + back_populates="requirement_evaluations" + ) score: Mapped[int] - requirement: Mapped['TaskRequirementOption'] = relationship() \ No newline at end of file + requirement: Mapped["TaskRequirementOption"] = relationship() diff --git a/backend/app/models/mixin/__init__.py b/backend/app/models/mixin/__init__.py index d5f758f..8ea0bdd 100644 --- a/backend/app/models/mixin/__init__.py +++ b/backend/app/models/mixin/__init__.py @@ -1,2 +1,2 @@ from .pkmixin import PKMixin -from .option_mixin import OptionMixin \ No newline at end of file +from .option_mixin import OptionMixin diff --git a/backend/app/models/mixin/option_mixin.py b/backend/app/models/mixin/option_mixin.py index da2aae6..3a121aa 100644 --- a/backend/app/models/mixin/option_mixin.py +++ b/backend/app/models/mixin/option_mixin.py @@ -1,5 +1,6 @@ from sqlalchemy.orm import Mapped, mapped_column + class OptionMixin: name: Mapped[str] = mapped_column(primary_key=True) - display_name: Mapped[str] \ No newline at end of file + display_name: Mapped[str] diff --git a/backend/app/models/role.py b/backend/app/models/role.py index dd3139d..31f9c47 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -1,4 +1,3 @@ -from typing import List from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base @@ -9,8 +8,8 @@ class Role(Base, PKMixin): __tablename__ = "roles" - name: Mapped[str] = mapped_column(default="Team", unique=True) + name: Mapped[str] = mapped_column(unique=True) - users: Mapped[List["User"]] = relationship( + users: Mapped[list["User"]] = relationship( secondary=user_roles, back_populates="roles" ) diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 22f9514..4380687 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -3,20 +3,22 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy import ForeignKey + class Submission(Base): - __tablename__ = 'submissions' - team_id: Mapped[int] = mapped_column(ForeignKey('teams.id'), primary_key=True) - team: Mapped['Team'] = relationship(back_populates='submission', single_parent=True) - urls: Mapped[list['SubmissionUrl']] = relationship(back_populates='submission') + __tablename__ = "submissions" + team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True) + team: Mapped["Team"] = relationship(back_populates="submission", single_parent=True) + urls: Mapped[list["SubmissionUrl"]] = relationship(back_populates="submission") + class SubmissionUrl(Base): - __tablename__ = 'submission_urls' - submission_id: Mapped[int] = mapped_column(ForeignKey('submissions.team_id')) - url_id: Mapped[int] = mapped_column(ForeignKey('submission_url_options.name')) + __tablename__ = "submission_urls" + submission_id: Mapped[int] = mapped_column(ForeignKey("submissions.team_id")) + url_id: Mapped[int] = mapped_column(ForeignKey("submission_url_options.name")) + + submission: Mapped["Submission"] = relationship(back_populates="urls") + url: Mapped["SubmissionUrlOption"] = relationship() - submission: Mapped['Submission'] = relationship(back_populates='urls') - url: Mapped['SubmissionUrlOption'] = relationship() class SubmissionUrlOption(Base, OptionMixin): - __tablename__ = 'submission_url_options' - \ No newline at end of file + __tablename__ = "submission_url_options" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index d7cb35f..36048e5 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -6,36 +6,52 @@ from datetime import datetime task_requirements = Table( - 'task_requirements', + "task_requirements", Base.metadata, - Column("requirement_id", ForeignKey("task_requirement_options.name", ondelete="CASCADE"), primary_key=True), + Column( + "requirement_id", + ForeignKey("task_requirement_options.name", ondelete="CASCADE"), + primary_key=True, + ), Column("task_id", ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), ) + class Task(Base, PKMixin): __tablename__ = "tasks" title: Mapped[str] description: Mapped[str] - tournament_id: Mapped[int] = mapped_column(ForeignKey('tournaments.id')) - tournament: Mapped['Tournament'] = relationship(back_populates='tasks') + tournament_id: Mapped[int] = mapped_column(ForeignKey("tournaments.id")) + tournament: Mapped["Tournament"] = relationship(back_populates="tasks") start_time: Mapped[datetime] end_time: Mapped[datetime] - status_id: Mapped[str] = mapped_column(ForeignKey('task_statuses.name')) - status: Mapped['TaskStatusOption'] = relationship(back_populates='tasks') - requirements: Mapped[list['TaskRequirementOption']] = relationship(secondary=task_requirements) + status_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) + status: Mapped["TaskStatusOption"] = relationship(back_populates="tasks") + requirements: Mapped[list["TaskRequirementOption"]] = relationship( + secondary=task_requirements + ) + class TaskStatusOption(Base, OptionMixin): - __tablename__ = 'task_statuses' - tasks: Mapped[list['Task']] = relationship(back_populates='status') + __tablename__ = "task_statuses" + tasks: Mapped[list["Task"]] = relationship(back_populates="status") + class TaskRequirementOption(Base, OptionMixin): - __tablename__ = 'task_requirement_options' - category_id: Mapped[str] = mapped_column(ForeignKey('task_statuses.name')) - category: Mapped['TaskRequirementCategory'] = relationship() + __tablename__ = "task_requirement_options" + category_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) + category: Mapped["TaskRequirementCategory"] = relationship() + class TaskRequirementCategory(Base, OptionMixin): - __tablename__ = 'task_requirement_categories' - category_id: Mapped[str] = mapped_column(ForeignKey('task_requirement_categories.name')) - category: Mapped['TaskRequirementCategory'] = relationship(back_populates='category') - task_requirement_options: Mapped[list['TaskRequirementOption']] = relationship(back_populates='category') + __tablename__ = "task_requirement_categories" + category_id: Mapped[str] = mapped_column( + ForeignKey("task_requirement_categories.name") + ) + category: Mapped["TaskRequirementCategory"] = relationship( + back_populates="category" + ) + task_requirement_options: Mapped[list["TaskRequirementOption"]] = relationship( + back_populates="category" + ) diff --git a/backend/app/models/team.py b/backend/app/models/team.py index ec65e23..46a1c19 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -8,12 +8,15 @@ class Team(Base, PKMixin): __tablename__ = "teams" - name: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] team_email: Mapped[str] contact_info: Mapped[str] - tournament_id: Mapped[int] + tournament_id: Mapped[int] = mapped_column(ForeignKey("tournaments.id")) captain_id: Mapped[int] = mapped_column(ForeignKey("team_members.id")) + tournament: Mapped["Tournament"] = relationship(back_populates="teams") + members: Mapped[list["TeamMember"]] = relationship(back_populates="team") + def __repr__(self): return f"" diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 1647aa8..4b0537e 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -16,18 +16,17 @@ class Tournament(Base, PKMixin): reg_start: Mapped[datetime] reg_end: Mapped[datetime] max_team: Mapped[int] - active_task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id')) - active_task: Mapped['Task'] = relationship() - tasks: Mapped[list['Task']] = relationship(back_populates='tournament') + active_task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id")) status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + teams: Mapped[list["Team"]] = relationship(back_populates="tournament") + active_task: Mapped["Task"] = relationship() + tasks: Mapped[list["Task"]] = relationship(back_populates="tournament") status: Mapped["TournamentStatusOption"] = relationship( back_populates="tournaments" ) - creator: Mapped["User"] = relationship( - back_populates="created_tournaments" - ) + creator: Mapped["User"] = relationship(back_populates="created_tournaments") def __repr__(self): return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index abd93f5..477793a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,3 @@ -from typing import List from datetime import datetime from sqlalchemy import ForeignKey, Table, Column, func from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -25,6 +24,9 @@ class User(Base, PKMixin): roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users" ) + created_tournaments: Mapped[list["Tournament"]] = relationship( + back_populates="creator" + ) def __repr__(self): - return f"" + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 6ad224f..d6fbfa0 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1 +1,5 @@ -from .user import UserModel \ No newline at end of file +from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel +from .task import TaskModel +from .team import TeamModel, TeamMemberModel +from .tournament import TournamentModels, TournamentStatusOptionModel +from .user import UserModel diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py new file mode 100644 index 0000000..6c25c62 --- /dev/null +++ b/backend/app/schemas/evaluation.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + + +class RequirementEvaluationModel(BaseModel): + score: int = Field(..., ge=0) + + +class SubmissionEvaluationModel(BaseModel): + submission_id: int = Field(..., gt=0) + jury_id: int = Field(..., gt=0) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..13cd05b --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing_extensions import Self +from pydantic import BaseModel, Field, field_validator, model_validator + + +class TaskModel(BaseModel): + title: str = Field(..., min_length=3, description="Short name of the task") + description: str = Field( + description="A detailed description of what needs to be done" + ) + start_time: datetime + end_time: datetime + tournament_id: int = Field(..., gt=0) + status_id: str = Field(..., gt=0) + + @field_validator("title") + @classmethod + def check_title(cls, value: str): + return value.strip() + + @field_validator("start_time") + @classmethod + def start_not_past(cls, value: datetime): + if value < datetime.now(): + raise ValueError("Task cannot start in the past") + return value + + @model_validator(mode="after") + def check_time_logic(self) -> Self: + if self.end_time <= self.start_time: + raise ValueError("end_time must be later than start_time") + return self diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py new file mode 100644 index 0000000..97b5b53 --- /dev/null +++ b/backend/app/schemas/team.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field, EmailStr, field_validator +from pydantic_extra_types.phone_numbers import PhoneNumber + + +class TeamModel(BaseModel): + name: str = Field(..., description="Name of the team") + team_email: EmailStr = Field(..., description="Contact email") + contact_info: PhoneNumber = Field(..., description="Phone number") + tournament_id: int = Field(..., gt=0) + captain_id: int = Field(..., gt=0) + + @field_validator("team_email") + @classmethod + def normalize_email(cls, value: EmailStr): + return value.lower() + + +class TeamMemberModel(BaseModel): + full_name: str = Field(..., min_length=3) + email: EmailStr = Field(..., description="Contact email") + telegram_username: str + educational_institution: str + team_id: int = Field(..., gt=0) diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py new file mode 100644 index 0000000..b641fae --- /dev/null +++ b/backend/app/schemas/tournament.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing_extensions import Self +from pydantic import BaseModel, Field, field_validator, model_validator + + +class TournamentModels(BaseModel): + title: str = Field(..., min_length=3) + description: str + start_date: datetime + reg_start: datetime + reg_end: datetime + max_team: int = Field(..., gt=0) + + @field_validator("title") + @classmethod + def check_title(cls, value: str): + return value.strip() + + @field_validator("reg_start") + @classmethod + def check__date(cls, value: datetime): + if value <= datetime.now(): + raise ValueError("Registration cannot start in the past") + return value + + @model_validator(mode="after") + def check_dates(self) -> Self: + if self.reg_end <= self.reg_start: + raise ValueError("reg_end must be later than reg_start") + if self.start_date <= self.reg_end: + raise ValueError("Tournament must start after registration ends") + + return self + + +class TournamentStatusOptionModel(BaseModel): + name: str = Field(..., min_length=3) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 1aac53b..3decf61 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,13 +1,11 @@ -from typing import List from pydantic import BaseModel, Field, EmailStr, field_validator class UserModel(BaseModel): - id: int full_name: str = Field(..., description="Username") email: EmailStr = Field(..., description="User email") password: str = Field(..., min_length=6, description="User password") - role: List[str] + role: list[str] @field_validator("full_name") @classmethod From 426882b9253758d3f094f9ed3d585f366693fa76 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sun, 22 Feb 2026 22:15:41 +0000 Subject: [PATCH 013/369] minimally configured for pytest --- backend/conftest.py | 1 + backend/pytest.ini | 5 +++++ backend/tests/__init__.py | 0 backend/tests/test_models.py | 0 requirements.txt | 4 ++++ 5 files changed, 10 insertions(+) create mode 100644 backend/conftest.py create mode 100644 backend/pytest.ini create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_models.py diff --git a/backend/conftest.py b/backend/conftest.py new file mode 100644 index 0000000..bb377e8 --- /dev/null +++ b/backend/conftest.py @@ -0,0 +1 @@ +import pytest \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..e1fb0d6 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] + +testpaths = tests + +python_files = tests.py test_*.py *_test.py \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 4ed1cc0..6b8bd0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,15 +16,19 @@ httpcore==1.0.9 httptools==0.7.1 httpx==0.28.1 idna==3.11 +iniconfig==2.3.0 Jinja2==3.1.6 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 +packaging==26.0 +pluggy==1.6.0 pydantic==2.12.5 pydantic-extra-types==2.11.0 pydantic-settings==2.13.0 pydantic_core==2.41.5 Pygments==2.19.2 +pytest==9.0.2 python-dotenv==1.2.1 python-multipart==0.0.22 PyYAML==6.0.3 From f668c81be3e70863feba51d45678e83a39890d25 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Mon, 23 Feb 2026 15:17:55 +0000 Subject: [PATCH 014/369] minor changes + tests for user model and roles --- backend/app/models/role.py | 2 +- backend/app/models/task.py | 25 +++++--- backend/app/models/team.py | 8 ++- backend/app/models/tournament.py | 8 ++- backend/app/models/user.py | 8 +-- backend/conftest.py | 29 ++++++++- backend/pytest.ini | 2 + backend/tests/test_models.py | 105 +++++++++++++++++++++++++++++++ requirements.txt | 1 + 9 files changed, 170 insertions(+), 18 deletions(-) diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 31f9c47..2bd9db6 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -8,7 +8,7 @@ class Role(Base, PKMixin): __tablename__ = "roles" - name: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] = mapped_column(nullable=False, unique=True) users: Mapped[list["User"]] = relationship( secondary=user_roles, back_populates="roles" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 36048e5..0268254 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -22,8 +22,12 @@ class Task(Base, PKMixin): title: Mapped[str] description: Mapped[str] - tournament_id: Mapped[int] = mapped_column(ForeignKey("tournaments.id")) - tournament: Mapped["Tournament"] = relationship(back_populates="tasks") + tournament_id: Mapped[int] = mapped_column( + ForeignKey("tournaments.id", use_alter=True, name="fk_task_tournament") + ) + tournament: Mapped["Tournament"] = relationship( + "Tournament", back_populates="tasks", foreign_keys="[Task.tournament_id]" + ) start_time: Mapped[datetime] end_time: Mapped[datetime] status_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) @@ -40,17 +44,22 @@ class TaskStatusOption(Base, OptionMixin): class TaskRequirementOption(Base, OptionMixin): __tablename__ = "task_requirement_options" - category_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) - category: Mapped["TaskRequirementCategory"] = relationship() + category_id: Mapped[str] = mapped_column( + ForeignKey("task_requirement_categories.name") + ) + category: Mapped["TaskRequirementCategory"] = relationship( + back_populates="task_requirement_options" + ) class TaskRequirementCategory(Base, OptionMixin): __tablename__ = "task_requirement_categories" - category_id: Mapped[str] = mapped_column( - ForeignKey("task_requirement_categories.name") + main_id: Mapped[str] = mapped_column(ForeignKey("task_requirement_categories.name")) + sub_categories: Mapped[list["TaskRequirementCategory"]] = relationship( + back_populates="parent_category" ) - category: Mapped["TaskRequirementCategory"] = relationship( - back_populates="category" + parent_category: Mapped["TaskRequirementCategory"] = relationship( + back_populates="sub_categories", remote_side="TaskRequirementCategory.name" ) task_requirement_options: Mapped[list["TaskRequirementOption"]] = relationship( back_populates="category" diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 46a1c19..2c8a036 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -15,7 +15,9 @@ class Team(Base, PKMixin): captain_id: Mapped[int] = mapped_column(ForeignKey("team_members.id")) tournament: Mapped["Tournament"] = relationship(back_populates="teams") - members: Mapped[list["TeamMember"]] = relationship(back_populates="team") + members: Mapped[list["TeamMember"]] = relationship( + back_populates="team", foreign_keys="TeamMember.team_id" + ) def __repr__(self): return f"" @@ -28,7 +30,9 @@ class TeamMember(Base, PKMixin): email: Mapped[str] = mapped_column(unique=True) telegram_username: Mapped[str] = mapped_column(unique=True) educational_institution: Mapped[str] - team_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) + team_id: Mapped[int] = mapped_column( + ForeignKey("teams.id", use_alter=True, name="fk_teammember_team") + ) team: Mapped["Team"] = relationship( back_populates="members", foreign_keys=[team_id] diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 4b0537e..1af9908 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -21,8 +21,12 @@ class Tournament(Base, PKMixin): creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) teams: Mapped[list["Team"]] = relationship(back_populates="tournament") - active_task: Mapped["Task"] = relationship() - tasks: Mapped[list["Task"]] = relationship(back_populates="tournament") + active_task: Mapped["Task"] = relationship( + "Task", foreign_keys="[Tournament.active_task_id]" + ) + tasks: Mapped[list["Task"]] = relationship( + "Task", back_populates="tournament", foreign_keys="[Task.tournament_id]" + ) status: Mapped["TournamentStatusOption"] = relationship( back_populates="tournaments" ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 477793a..1f7d7f1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,10 +16,10 @@ class User(Base, PKMixin): __tablename__ = "users" - full_name: Mapped[str] - email: Mapped[str] = mapped_column(unique=True) - password: Mapped[str] - cleated_at: Mapped[datetime] = mapped_column(server_default=func.now()) + full_name: Mapped[str] = mapped_column(nullable=False) + email: Mapped[str] = mapped_column(nullable=False, unique=True) + password: Mapped[str] = mapped_column(nullable=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users" diff --git a/backend/conftest.py b/backend/conftest.py index bb377e8..770b66e 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1 +1,28 @@ -import pytest \ No newline at end of file +import pytest +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from app.models import Base + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +engine = create_async_engine( + TEST_DATABASE_URL, +) + +AsyncTestingSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False) + + +@pytest.fixture(scope="session", autouse=True) +async def setup_database(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +async def db_session(): + async with AsyncTestingSessionLocal() as session: + yield session + await session.rollback() diff --git a/backend/pytest.ini b/backend/pytest.ini index e1fb0d6..75d353f 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,5 +1,7 @@ [pytest] +addopts = -v +asyncio_mode = auto testpaths = tests python_files = tests.py test_*.py *_test.py \ No newline at end of file diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index e69de29..014544f 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -0,0 +1,105 @@ +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload +from app.models import User, Role + + +async def test_create_user(db_session): + user = User( + full_name="Ivan Ivanov", + email="ivan@example.com", + password="very_strong_password", + ) + db_session.add(user) + await db_session.commit() + + assert user.id is not None + assert user.full_name == "Ivan Ivanov" + assert user.email == "ivan@example.com" + assert user.created_at is not None + + +async def test_create_user_duplicate_email(db_session): + + user1 = User( + full_name="Ivan Ivanov", + email="unique@example.com", + password="very_strong_password", + ) + db_session.add(user1) + await db_session.commit() + + user2 = User( + full_name="Petro Petrov", + email="unique@example.com", + password="very_strong_password", + ) + db_session.add(user2) + + with pytest.raises(IntegrityError): + await db_session.commit() + + await db_session.rollback() + + +async def test_create_user_without_password(db_session): + + user = User( + full_name="No Password", + email="nopass@example.com", + ) + db_session.add(user) + + with pytest.raises(IntegrityError): + await db_session.commit() + + await db_session.rollback() + + +async def test_create_role(db_session): + role = Role(name="organizer") + db_session.add(role) + await db_session.commit() + await db_session.refresh(role) + + assert role.id is not None + assert role.name == "organizer" + + +async def test_role_name_unique_constraint(db_session): + + role1 = Role(name="jury") + db_session.add(role1) + await db_session.commit() + + role2 = Role(name="jury") + db_session.add(role2) + + with pytest.raises(IntegrityError): + await db_session.commit() + + await db_session.rollback() + + +async def test_user_roles_relationship(db_session): + admin_role = Role(name="admin") + user_role = Role(name="user") + db_session.add_all([admin_role, user_role]) + await db_session.commit() + + user = User( + full_name="Power User", + email="admin@example.com", + password="secret_password", + roles=[admin_role, user_role], + ) + db_session.add(user) + await db_session.commit() + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + result = await db_session.execute(stmt) + user = result.scalar_one() + + assert len(user.roles) == 2 + assert any(role.name == "admin" for role in user.roles) + assert any(role.name == "user" for role in user.roles) diff --git a/requirements.txt b/requirements.txt index 6b8bd0a..9cdf9b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,7 @@ pydantic-settings==2.13.0 pydantic_core==2.41.5 Pygments==2.19.2 pytest==9.0.2 +pytest-asyncio==1.3.0 python-dotenv==1.2.1 python-multipart==0.0.22 PyYAML==6.0.3 From 6ab7ad521665d0fc1d24fde0631d30f6d151ae90 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Wed, 25 Feb 2026 22:57:32 +0000 Subject: [PATCH 015/369] wrote fixture and fix models --- backend/app/models/role.py | 3 + backend/app/models/task.py | 2 +- backend/app/models/team.py | 2 +- backend/app/models/tournament.py | 6 +- backend/conftest.py | 112 ++++++++++++++++++++++++++++++- backend/tests/test_models.py | 82 +++++++++------------- 6 files changed, 148 insertions(+), 59 deletions(-) diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 2bd9db6..27b977d 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -13,3 +13,6 @@ class Role(Base, PKMixin): users: Mapped[list["User"]] = relationship( secondary=user_roles, back_populates="roles" ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 0268254..6c907be 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -26,7 +26,7 @@ class Task(Base, PKMixin): ForeignKey("tournaments.id", use_alter=True, name="fk_task_tournament") ) tournament: Mapped["Tournament"] = relationship( - "Tournament", back_populates="tasks", foreign_keys="[Task.tournament_id]" + "Tournament", back_populates="tasks", foreign_keys="Task.tournament_id" ) start_time: Mapped[datetime] end_time: Mapped[datetime] diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 2c8a036..dccbaf7 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -26,7 +26,7 @@ def __repr__(self): class TeamMember(Base, PKMixin): __tablename__ = "team_members" - full_name: Mapped[str] + full_name: Mapped[str] = mapped_column(nullable=False) email: Mapped[str] = mapped_column(unique=True) telegram_username: Mapped[str] = mapped_column(unique=True) educational_institution: Mapped[str] diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 1af9908..92521da 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -16,16 +16,16 @@ class Tournament(Base, PKMixin): reg_start: Mapped[datetime] reg_end: Mapped[datetime] max_team: Mapped[int] - active_task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id")) + active_task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"), nullable=True) status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) teams: Mapped[list["Team"]] = relationship(back_populates="tournament") active_task: Mapped["Task"] = relationship( - "Task", foreign_keys="[Tournament.active_task_id]" + "Task", foreign_keys="Tournament.active_task_id" ) tasks: Mapped[list["Task"]] = relationship( - "Task", back_populates="tournament", foreign_keys="[Task.tournament_id]" + "Task", back_populates="tournament", foreign_keys="Task.tournament_id" ) status: Mapped["TournamentStatusOption"] = relationship( back_populates="tournaments" diff --git a/backend/conftest.py b/backend/conftest.py index 770b66e..c64557e 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,7 +1,17 @@ import pytest +from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from app.models import Base +from app.models import ( + Base, + User, + Role, + Team, + TeamMember, + Tournament, + TournamentStatusOption, + Task, +) TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -12,7 +22,7 @@ AsyncTestingSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False) -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="function", autouse=True) async def setup_database(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -25,4 +35,100 @@ async def setup_database(): async def db_session(): async with AsyncTestingSessionLocal() as session: yield session - await session.rollback() + + +@pytest.fixture +async def user(db_session): + user = User( + full_name="Test User", + email="test@example.com", + password="very_strong_password", + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def role(db_session): + role = Role(name="jury") + db_session.add(role) + await db_session.commit() + await db_session.refresh(role) + return role + + +@pytest.fixture +async def tournament_status(db_session): + status = TournamentStatusOption(name="Registration Open") + db_session.add(status) + await db_session.commit() + await db_session.refresh(status) + return status + + +@pytest.fixture +async def tournament(db_session, user, tournament_status): + tournament = Tournament( + title="Test Tournament", + description="Test Description", + start_date=datetime.now() + timedelta(days=10), + reg_start=datetime.now(), + reg_end=datetime.now() + timedelta(days=5), + max_team=50, + status_id=tournament_status.id, + creator_id=user.id, + active_task_id=None, + ) + db_session.add(tournament) + await db_session.commit() + await db_session.refresh(tournament) + return tournament + + +@pytest.fixture +async def task(db_session, tournament): + task = Task( + title="Test Title", + description="Test Description", + tournament_id=tournament.id, + start_time=datetime.now(), + end_time=datetime.now() + timedelta(hours=2), + status_id="draft", + ) + + db_session.add(task) + await db_session.commit() + await db_session.refresh(task) + return task + + +@pytest.fixture +async def team(db_session, user, tournament): + team = Team( + name="TestTeam", + team_email="test@example.com", + contact_info="0680000000", + tournament_id=tournament.id, + captain_id=user.id, + ) + db_session.add(team) + await db_session.commit() + await db_session.refresh(team) + return team + + +@pytest.fixture +async def team_member(db_session, team): + team_member = TeamMember( + full_name="Test Name", + email="test@example.com", + telegram_username="@test_username", + educational_institution="Test Location", + team_id=team.id, + ) + db_session.add(team_member) + await db_session.commit() + await db_session.refresh(team_member) + return team_member diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 014544f..0d02186 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -5,40 +5,24 @@ from app.models import User, Role -async def test_create_user(db_session): - user = User( - full_name="Ivan Ivanov", - email="ivan@example.com", - password="very_strong_password", - ) - db_session.add(user) - await db_session.commit() +async def test_create_user(user): assert user.id is not None - assert user.full_name == "Ivan Ivanov" - assert user.email == "ivan@example.com" + assert user.full_name == "Test User" + assert user.email == "test@example.com" assert user.created_at is not None -async def test_create_user_duplicate_email(db_session): - - user1 = User( - full_name="Ivan Ivanov", - email="unique@example.com", - password="very_strong_password", - ) - db_session.add(user1) - await db_session.commit() - +async def test_create_user_duplicate_email(db_session, user): user2 = User( full_name="Petro Petrov", - email="unique@example.com", + email=user.email, password="very_strong_password", ) db_session.add(user2) with pytest.raises(IntegrityError): - await db_session.commit() + await db_session.flush() await db_session.rollback() @@ -57,24 +41,16 @@ async def test_create_user_without_password(db_session): await db_session.rollback() -async def test_create_role(db_session): - role = Role(name="organizer") - db_session.add(role) - await db_session.commit() - await db_session.refresh(role) +async def test_create_role(role): assert role.id is not None - assert role.name == "organizer" - + assert role.name == "jury" -async def test_role_name_unique_constraint(db_session): - role1 = Role(name="jury") - db_session.add(role1) - await db_session.commit() +async def test_role_name_unique_constraint(db_session, role): - role2 = Role(name="jury") - db_session.add(role2) + role_duplicate = Role(name="jury") + db_session.add(role_duplicate) with pytest.raises(IntegrityError): await db_session.commit() @@ -82,24 +58,28 @@ async def test_role_name_unique_constraint(db_session): await db_session.rollback() -async def test_user_roles_relationship(db_session): +async def test_user_roles_relationship(db_session, user, role): admin_role = Role(name="admin") - user_role = Role(name="user") - db_session.add_all([admin_role, user_role]) - await db_session.commit() + db_session.add(admin_role) + + await db_session.refresh(user, attribute_names=["roles"]) + user.roles.extend([role, admin_role]) + + await db_session.flush() - user = User( - full_name="Power User", - email="admin@example.com", - password="secret_password", - roles=[admin_role, user_role], - ) - db_session.add(user) - await db_session.commit() stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) result = await db_session.execute(stmt) - user = result.scalar_one() - assert len(user.roles) == 2 - assert any(role.name == "admin" for role in user.roles) - assert any(role.name == "user" for role in user.roles) + db_user = result.unique().scalar_one() + + assert len(db_user.roles) == 2 + + role_names = [r.name for r in db_user.roles] + assert "admin" in role_names + assert "jury" in role_names + + +async def test_create_team_member(db_session, team, team_member): + + assert team_member.id is not None + assert team_member.team_id == team.id From e32e4f8249439541588ba26a973c11442eae5424 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 26 Feb 2026 16:49:35 +0000 Subject: [PATCH 016/369] wrote a few more tests --- backend/app/models/team.py | 20 +++-- backend/conftest.py | 2 +- backend/tests/test_models.py | 145 ++++++++++++++++++++++++++++++++++- 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/backend/app/models/team.py b/backend/app/models/team.py index dccbaf7..bf58c42 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -8,15 +8,19 @@ class Team(Base, PKMixin): __tablename__ = "teams" - name: Mapped[str] - team_email: Mapped[str] - contact_info: Mapped[str] + name: Mapped[str] = mapped_column(nullable=False, unique=True) + team_email: Mapped[str] = mapped_column(nullable=False, unique=True) + contact_info: Mapped[str] = mapped_column(nullable=False, unique=True) tournament_id: Mapped[int] = mapped_column(ForeignKey("tournaments.id")) - captain_id: Mapped[int] = mapped_column(ForeignKey("team_members.id")) + captain_id: Mapped[int] = mapped_column( + ForeignKey("team_members.id", ondelete="CASCADE"), nullable=True + ) tournament: Mapped["Tournament"] = relationship(back_populates="teams") members: Mapped[list["TeamMember"]] = relationship( - back_populates="team", foreign_keys="TeamMember.team_id" + back_populates="team", + foreign_keys="TeamMember.team_id", + cascade="all, delete-orphan", ) def __repr__(self): @@ -27,9 +31,9 @@ class TeamMember(Base, PKMixin): __tablename__ = "team_members" full_name: Mapped[str] = mapped_column(nullable=False) - email: Mapped[str] = mapped_column(unique=True) - telegram_username: Mapped[str] = mapped_column(unique=True) - educational_institution: Mapped[str] + email: Mapped[str] = mapped_column(nullable=False, unique=True) + telegram_username: Mapped[str] = mapped_column(nullable=False, unique=True) + educational_institution: Mapped[str] = mapped_column(nullable=False) team_id: Mapped[int] = mapped_column( ForeignKey("teams.id", use_alter=True, name="fk_teammember_team") ) diff --git a/backend/conftest.py b/backend/conftest.py index c64557e..3cf96ca 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -107,7 +107,7 @@ async def task(db_session, tournament): @pytest.fixture async def team(db_session, user, tournament): team = Team( - name="TestTeam", + name="Test Team", team_email="test@example.com", contact_info="0680000000", tournament_id=tournament.id, diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 0d02186..a77b349 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -2,7 +2,15 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload -from app.models import User, Role +from app.models import ( + User, + Role, + TeamMember, + Team, + Task, + TaskRequirementCategory, + TaskRequirementOption, +) async def test_create_user(user): @@ -36,7 +44,7 @@ async def test_create_user_without_password(db_session): db_session.add(user) with pytest.raises(IntegrityError): - await db_session.commit() + await db_session.flush() await db_session.rollback() @@ -53,7 +61,7 @@ async def test_role_name_unique_constraint(db_session, role): db_session.add(role_duplicate) with pytest.raises(IntegrityError): - await db_session.commit() + await db_session.flush() await db_session.rollback() @@ -79,7 +87,136 @@ async def test_user_roles_relationship(db_session, user, role): assert "jury" in role_names -async def test_create_team_member(db_session, team, team_member): +async def test_create_team_member(team, team_member): assert team_member.id is not None assert team_member.team_id == team.id + + +async def test_team_member_without_data(db_session): + member = TeamMember() + + db_session.add(member) + + with pytest.raises(IntegrityError): + await db_session.flush() + + await db_session.rollback() + + +async def test_team_member_duplicate_email(db_session, team, team_member): + duplicate = TeamMember( + full_name="Test Name", + email=team_member.email, + telegram_username="@test2", + educational_institution="Test School", + team_id=team.id, + ) + + db_session.add(duplicate) + + with pytest.raises(IntegrityError): + await db_session.flush() + + await db_session.rollback() + + +async def test_create_team(team): + + assert team.id is not None + assert team.name == "Test Team" + assert team.team_email == "test@example.com" + assert team.contact_info == "0680000000" + + +async def test_team_without_data(db_session): + team = Team() + + db_session.add(team) + + with pytest.raises(IntegrityError): + await db_session.flush() + + await db_session.rollback() + + +async def test_team_duplicate_email(db_session, team, tournament): + duplicate_team = Team( + name="Test Name", + team_email=team.team_email, + contact_info="0680000001", + tournament_id=tournament.id, + ) + + db_session.add(duplicate_team) + + with pytest.raises(IntegrityError): + await db_session.flush() + + await db_session.rollback() + + +async def test_set_team_captain(db_session, team, team_member): + team.captain_id = team_member.id + db_session.add(team) + await db_session.commit() + + await db_session.refresh(team) + assert team.captain_id == team_member.id + + +async def test_team_team_member_relationship(db_session, team, team_member): + stmt = select(Team).where(Team.id == team.id).options(selectinload(Team.members)) + result = await db_session.execute(stmt) + + db_team = result.unique().scalar_one() + + assert len(db_team.members) == 1 + assert db_team.members[0].id == team_member.id + + +async def test_team_cascade_delete_members(db_session, team, team_member): + member_id = team_member.id + + await db_session.delete(team) + await db_session.commit() + + stmt = select(TeamMember).where(TeamMember.id == member_id) + result = await db_session.execute(stmt) + + assert result.scalar_one_or_none() is None + + +async def test_create_task(task, tournament): + + assert task.id is not None + assert task.title == "Test Title" + assert task.tournament_id == tournament.id + assert task.status_id == "draft" + + +async def test_task_requirements_relationship(db_session, task): + + category = TaskRequirementCategory( + name="Test Category", display_name="Test Category", main_id="Test Category" + ) + option = TaskRequirementOption( + name="Test Option", display_name="Test Option", category=category + ) + db_session.add_all([category, option]) + await db_session.flush() + + await db_session.refresh(task, attribute_names=["requirements"]) + + task.requirements.append(option) + await db_session.commit() + + stmt = ( + select(Task).where(Task.id == task.id).options(selectinload(Task.requirements)) + ) + + result = await db_session.execute(stmt) + db_task = result.unique().scalar_one() + + assert len(db_task.requirements) == 1 + assert db_task.requirements[0].name == "Test Option" From f437d9101e8f06c52b4193230bc36f41efa98498 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 27 Feb 2026 15:28:40 +0000 Subject: [PATCH 017/369] tests for task --- backend/app/models/task.py | 2 +- backend/tests/test_models.py | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 6c907be..c571869 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -54,7 +54,7 @@ class TaskRequirementOption(Base, OptionMixin): class TaskRequirementCategory(Base, OptionMixin): __tablename__ = "task_requirement_categories" - main_id: Mapped[str] = mapped_column(ForeignKey("task_requirement_categories.name")) + main_id: Mapped[str] = mapped_column(ForeignKey("task_requirement_categories.name"), nullable=True) sub_categories: Mapped[list["TaskRequirementCategory"]] = relationship( back_populates="parent_category" ) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index a77b349..d729468 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,4 +1,5 @@ import pytest +from datetime import datetime, timedelta from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload @@ -220,3 +221,59 @@ async def test_task_requirements_relationship(db_session, task): assert len(db_task.requirements) == 1 assert db_task.requirements[0].name == "Test Option" + + +async def test_task_without_data(db_session): + task = Task() + + db_session.add(task) + + with pytest.raises(IntegrityError): + await db_session.flush() + + await db_session.rollback() + + +async def test_task_invalid_time(db_session, tournament): + task = Task( + title="Test Task", + description="...", + tournament_id=tournament.id, + start_time=datetime.now(), + end_time=datetime.now() - timedelta(hours=1), + status_id="draft", + ) + db_session.add(task) + + await db_session.commit() + assert task.end_time < task.start_time + + +async def test_task_category_hierarchy(db_session): + parent = TaskRequirementCategory( + name="Programming", display_name="Programming Languages" + ) + db_session.add(parent) + await db_session.flush() + + child = TaskRequirementCategory( + name="Python", + display_name="Python Language", + main_id="Programming", + parent_category=parent, + ) + db_session.add(child) + await db_session.commit() + + stmt = ( + select(TaskRequirementCategory) + .where(TaskRequirementCategory.name == "Programming") + .options(selectinload(TaskRequirementCategory.sub_categories)) + ) + + result = await db_session.execute(stmt) + db_parent = result.unique().scalar_one() + + assert len(db_parent.sub_categories) == 1 + assert db_parent.sub_categories[0].name == "Python" + assert db_parent.sub_categories[0].parent_category.name == "Programming" From af443f5ff712ed0be139cf50120e3e2d3ba62161 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 19:10:39 +0200 Subject: [PATCH 018/369] feat(routes): implement GET tournaments and tournament --- backend/app/dependencies/__init__.py | 1 + backend/app/dependencies/session.py | 6 ++++++ backend/app/main.py | 3 ++- backend/app/routes/tournaments.py | 21 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 backend/app/dependencies/__init__.py create mode 100644 backend/app/dependencies/session.py create mode 100644 backend/app/routes/tournaments.py diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py new file mode 100644 index 0000000..3801f16 --- /dev/null +++ b/backend/app/dependencies/__init__.py @@ -0,0 +1 @@ +from .session import Session \ No newline at end of file diff --git a/backend/app/dependencies/session.py b/backend/app/dependencies/session.py new file mode 100644 index 0000000..6d5c778 --- /dev/null +++ b/backend/app/dependencies/session.py @@ -0,0 +1,6 @@ +from app.db import get_session +from typing import Annotated +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +Session = Annotated[AsyncSession, Depends(get_session)] \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 95f3f53..4c6e18c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI - +import app.routes.tournaments as tournaments app = FastAPI() +app.include_router(tournaments.router) \ No newline at end of file diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py new file mode 100644 index 0000000..1c9610a --- /dev/null +++ b/backend/app/routes/tournaments.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas import TournamentModels +from app.models import Tournament +from app.dependencies import Session +from sqlalchemy import select + +router = APIRouter(prefix='/tournaments', tags=['tournaments']) + +@router.get('/', response_model=list[TournamentModels]) +async def tournaments(session: Session): + statement = select(Tournament) + tournaments = await session.execute(statement) + return tournaments.all() + +@router.get('/{tournament_id}/', response_model=TournamentModels) +async def tournament(tournament_id: int, session: Session): + statement = select(Tournament).where(Tournament.id==tournament_id) + tournament = await session.execute(statement) + if not tournament.first(): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') + return tournament.first() \ No newline at end of file From 945735cec650eaa7c596e2377d38bbe43e02f8ec Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 19:27:28 +0200 Subject: [PATCH 019/369] feat(routes): implement GET users and user --- backend/app/main.py | 4 +++- backend/app/routes/users.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 backend/app/routes/users.py diff --git a/backend/app/main.py b/backend/app/main.py index 4c6e18c..05071de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,7 @@ from fastapi import FastAPI import app.routes.tournaments as tournaments +import app.routes.users as users app = FastAPI() -app.include_router(tournaments.router) \ No newline at end of file +app.include_router(tournaments.router) +app.include_router(users.router) \ No newline at end of file diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py new file mode 100644 index 0000000..5ea26f1 --- /dev/null +++ b/backend/app/routes/users.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas import UserModel +from app.models import User +from app.dependencies import Session +from sqlalchemy import select + +router = APIRouter(prefix='/users', tags=['users']) + +@router.get('/', response_model=list[UserModel]) +async def users(session: Session): + statement = select(User) + users = await session.execute(statement) + return users.all() + +@router.get('/{user_id}/', response_model=UserModel) +async def user(user_id: int, session: Session): + statement = select(User).where(User.id==user_id) + user = await session.execute(statement) + if not user.first(): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') + return user.first() \ No newline at end of file From f01ffd5a5e94094c05c36d67a5e5d4e71bd4a3d0 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 19:35:31 +0200 Subject: [PATCH 020/369] chore: udate requirements.txt --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/requirements.txt b/requirements.txt index 9cdf9b8..7c5d0aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,10 +2,12 @@ aiosqlite==0.22.1 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 +backports.asyncio.runner==1.2.0 certifi==2026.1.4 click==8.3.1 dnspython==2.8.0 email-validator==2.3.0 +exceptiongroup==1.3.1 fastapi==0.129.0 fastapi-cli==0.0.23 fastapi-cloud-cli==0.13.0 @@ -22,6 +24,7 @@ markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 packaging==26.0 +phonenumbers==9.0.25 pluggy==1.6.0 pydantic==2.12.5 pydantic-extra-types==2.11.0 @@ -30,6 +33,8 @@ pydantic_core==2.41.5 Pygments==2.19.2 pytest==9.0.2 pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +pytest_async==0.1.1 python-dotenv==1.2.1 python-multipart==0.0.22 PyYAML==6.0.3 @@ -40,6 +45,7 @@ sentry-sdk==2.53.0 shellingham==1.5.4 SQLAlchemy==2.0.46 starlette==0.52.1 +tomli==2.4.0 typer==0.24.0 typing-inspection==0.4.2 typing_extensions==4.15.0 From 4e7147dc1853dc1cb72441f38aacb5fce8af4381 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 20:05:00 +0200 Subject: [PATCH 021/369] feat: init alembic --- backend/alembic.ini | 116 +++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 93 ++++++++++++++ backend/alembic/script.py.mako | 28 ++++ backend/alembic/versions/1afa2bfb3587_.py | 86 +++++++++++++ backend/alembic/versions/9c3d5ce955b1_.py | 150 ++++++++++++++++++++++ backend/alembic/versions/b6b9a8bb78e1_.py | 68 ++++++++++ requirements.txt | 2 + 8 files changed, 544 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/1afa2bfb3587_.py create mode 100644 backend/alembic/versions/9c3d5ce955b1_.py create mode 100644 backend/alembic/versions/b6b9a8bb78e1_.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..42c1090 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8b9212e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,93 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from app.config import settings +from app.models import Base + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option('sqlalchemy.url', settings.SQLALCHEMY_DATABASE_URI) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/1afa2bfb3587_.py b/backend/alembic/versions/1afa2bfb3587_.py new file mode 100644 index 0000000..58d615a --- /dev/null +++ b/backend/alembic/versions/1afa2bfb3587_.py @@ -0,0 +1,86 @@ +"""empty message + +Revision ID: 1afa2bfb3587 +Revises: b6b9a8bb78e1 +Create Date: 2026-03-02 19:58:36.054914 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1afa2bfb3587' +down_revision: Union[str, Sequence[str], None] = 'b6b9a8bb78e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_requirement_categories', ['main_id'], ['name']) + batch_op.drop_column('category_id') + + with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) + + with op.batch_alter_table('teams', schema=None) as batch_op: + batch_op.alter_column('captain_id', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.create_unique_constraint(None, ['team_email']) + batch_op.create_unique_constraint(None, ['contact_info']) + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'tournaments', ['tournament_id'], ['id']) + batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.alter_column('active_task_id', + existing_type=sa.INTEGER(), + nullable=True) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + batch_op.drop_column('cleated_at') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + batch_op.drop_column('created_at') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.alter_column('active_task_id', + existing_type=sa.INTEGER(), + nullable=False) + + with op.batch_alter_table('teams', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id']) + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_constraint(None, type_='unique') + batch_op.alter_column('captain_id', + existing_type=sa.INTEGER(), + nullable=False) + + with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_statuses', ['category_id'], ['name']) + + with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: + batch_op.add_column(sa.Column('category_id', sa.VARCHAR(), nullable=False)) + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/9c3d5ce955b1_.py b/backend/alembic/versions/9c3d5ce955b1_.py new file mode 100644 index 0000000..1cb0162 --- /dev/null +++ b/backend/alembic/versions/9c3d5ce955b1_.py @@ -0,0 +1,150 @@ +"""empty message + +Revision ID: 9c3d5ce955b1 +Revises: 1afa2bfb3587 +Create Date: 2026-03-02 20:04:34.646529 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9c3d5ce955b1' +down_revision: Union[str, Sequence[str], None] = '1afa2bfb3587' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('roles', + sa.Column('name', sa.String(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('task_requirement_categories', + sa.Column('main_id', sa.String(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['main_id'], ['task_requirement_categories.name'], ), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('task_statuses', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('team_members', + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('telegram_username', sa.String(), nullable=False), + sa.Column('educational_institution', sa.String(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], name='fk_teammember_team', use_alter=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('telegram_username') + ) + op.create_table('tournament_status_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('password', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('task_requirement_options', + sa.Column('category_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['task_requirement_categories.name'], ), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('tasks', + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.Column('start_time', sa.DateTime(), nullable=False), + sa.Column('end_time', sa.DateTime(), nullable=False), + sa.Column('status_id', sa.String(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['status_id'], ['task_statuses.name'], ), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], name='fk_task_tournament', use_alter=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_roles', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'role_id') + ) + op.create_table('task_requirements', + sa.Column('requirement_id', sa.String(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('requirement_id', 'task_id') + ) + op.create_table('tournaments', + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('start_date', sa.DateTime(), nullable=False), + sa.Column('reg_start', sa.DateTime(), nullable=False), + sa.Column('reg_end', sa.DateTime(), nullable=False), + sa.Column('max_team', sa.Integer(), nullable=False), + sa.Column('active_task_id', sa.Integer(), nullable=True), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('creator_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['active_task_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['status_id'], ['tournament_status_options.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('teams', + sa.Column('name', sa.String(), nullable=False), + sa.Column('team_email', sa.String(), nullable=False), + sa.Column('contact_info', sa.String(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.Column('captain_id', sa.Integer(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['captain_id'], ['team_members.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('contact_info'), + sa.UniqueConstraint('name'), + sa.UniqueConstraint('team_email') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('teams') + op.drop_table('tournaments') + op.drop_table('task_requirements') + op.drop_table('user_roles') + op.drop_table('tasks') + op.drop_table('task_requirement_options') + op.drop_table('users') + op.drop_table('tournament_status_options') + op.drop_table('team_members') + op.drop_table('task_statuses') + op.drop_table('task_requirement_categories') + op.drop_table('roles') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/b6b9a8bb78e1_.py b/backend/alembic/versions/b6b9a8bb78e1_.py new file mode 100644 index 0000000..f7bc1a3 --- /dev/null +++ b/backend/alembic/versions/b6b9a8bb78e1_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: b6b9a8bb78e1 +Revises: +Create Date: 2026-03-02 19:57:51.433308 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b6b9a8bb78e1' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_requirement_categories', sa.Column('main_id', sa.String(), nullable=True)) + op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['main_id'], ['name']) + op.drop_column('task_requirement_categories', 'category_id') + op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_options', 'task_requirement_categories', ['category_id'], ['name']) + op.alter_column('teams', 'captain_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_unique_constraint(None, 'teams', ['team_email']) + op.create_unique_constraint(None, 'teams', ['contact_info']) + op.drop_constraint(None, 'teams', type_='foreignkey') + op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'teams', 'tournaments', ['tournament_id'], ['id']) + op.alter_column('tournaments', 'active_task_id', + existing_type=sa.INTEGER(), + nullable=True) + op.add_column('users', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + op.drop_column('users', 'cleated_at') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + op.drop_column('users', 'created_at') + op.alter_column('tournaments', 'active_task_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_constraint(None, 'teams', type_='foreignkey') + op.drop_constraint(None, 'teams', type_='foreignkey') + op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id']) + op.drop_constraint(None, 'teams', type_='unique') + op.drop_constraint(None, 'teams', type_='unique') + op.alter_column('teams', 'captain_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_options', 'task_statuses', ['category_id'], ['name']) + op.add_column('task_requirement_categories', sa.Column('category_id', sa.VARCHAR(), nullable=False)) + op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['category_id'], ['name']) + op.drop_column('task_requirement_categories', 'main_id') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 7c5d0aa..124bd68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiosqlite==0.22.1 +alembic==1.18.4 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 @@ -20,6 +21,7 @@ httpx==0.28.1 idna==3.11 iniconfig==2.3.0 Jinja2==3.1.6 +Mako==1.3.10 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 From d77f224dc2ab4fbaa9ecfc3c43f160dccb08f909 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 20:05:27 +0200 Subject: [PATCH 022/369] chore: udate requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 124bd68..09f3356 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,8 @@ alembic==1.18.4 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 +async-timeout==5.0.1 +asyncpg==0.31.0 backports.asyncio.runner==1.2.0 certifi==2026.1.4 click==8.3.1 From 88a6185ecbb4787026b3e33b821e7473ac9a1418 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 20:05:00 +0200 Subject: [PATCH 023/369] feat: init alembic --- backend/alembic.ini | 116 +++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 93 ++++++++++++++ backend/alembic/script.py.mako | 28 ++++ backend/alembic/versions/1afa2bfb3587_.py | 86 +++++++++++++ backend/alembic/versions/9c3d5ce955b1_.py | 150 ++++++++++++++++++++++ backend/alembic/versions/b6b9a8bb78e1_.py | 68 ++++++++++ requirements.txt | 2 + 8 files changed, 544 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/1afa2bfb3587_.py create mode 100644 backend/alembic/versions/9c3d5ce955b1_.py create mode 100644 backend/alembic/versions/b6b9a8bb78e1_.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..42c1090 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8b9212e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,93 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from app.config import settings +from app.models import Base + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option('sqlalchemy.url', settings.SQLALCHEMY_DATABASE_URI) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/1afa2bfb3587_.py b/backend/alembic/versions/1afa2bfb3587_.py new file mode 100644 index 0000000..58d615a --- /dev/null +++ b/backend/alembic/versions/1afa2bfb3587_.py @@ -0,0 +1,86 @@ +"""empty message + +Revision ID: 1afa2bfb3587 +Revises: b6b9a8bb78e1 +Create Date: 2026-03-02 19:58:36.054914 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1afa2bfb3587' +down_revision: Union[str, Sequence[str], None] = 'b6b9a8bb78e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_requirement_categories', ['main_id'], ['name']) + batch_op.drop_column('category_id') + + with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) + + with op.batch_alter_table('teams', schema=None) as batch_op: + batch_op.alter_column('captain_id', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.create_unique_constraint(None, ['team_email']) + batch_op.create_unique_constraint(None, ['contact_info']) + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'tournaments', ['tournament_id'], ['id']) + batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.alter_column('active_task_id', + existing_type=sa.INTEGER(), + nullable=True) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + batch_op.drop_column('cleated_at') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + batch_op.drop_column('created_at') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.alter_column('active_task_id', + existing_type=sa.INTEGER(), + nullable=False) + + with op.batch_alter_table('teams', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id']) + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_constraint(None, type_='unique') + batch_op.alter_column('captain_id', + existing_type=sa.INTEGER(), + nullable=False) + + with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_statuses', ['category_id'], ['name']) + + with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: + batch_op.add_column(sa.Column('category_id', sa.VARCHAR(), nullable=False)) + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/9c3d5ce955b1_.py b/backend/alembic/versions/9c3d5ce955b1_.py new file mode 100644 index 0000000..1cb0162 --- /dev/null +++ b/backend/alembic/versions/9c3d5ce955b1_.py @@ -0,0 +1,150 @@ +"""empty message + +Revision ID: 9c3d5ce955b1 +Revises: 1afa2bfb3587 +Create Date: 2026-03-02 20:04:34.646529 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9c3d5ce955b1' +down_revision: Union[str, Sequence[str], None] = '1afa2bfb3587' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('roles', + sa.Column('name', sa.String(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('task_requirement_categories', + sa.Column('main_id', sa.String(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['main_id'], ['task_requirement_categories.name'], ), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('task_statuses', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('team_members', + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('telegram_username', sa.String(), nullable=False), + sa.Column('educational_institution', sa.String(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], name='fk_teammember_team', use_alter=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('telegram_username') + ) + op.create_table('tournament_status_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('password', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('task_requirement_options', + sa.Column('category_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['task_requirement_categories.name'], ), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('tasks', + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.Column('start_time', sa.DateTime(), nullable=False), + sa.Column('end_time', sa.DateTime(), nullable=False), + sa.Column('status_id', sa.String(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['status_id'], ['task_statuses.name'], ), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], name='fk_task_tournament', use_alter=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_roles', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'role_id') + ) + op.create_table('task_requirements', + sa.Column('requirement_id', sa.String(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('requirement_id', 'task_id') + ) + op.create_table('tournaments', + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('start_date', sa.DateTime(), nullable=False), + sa.Column('reg_start', sa.DateTime(), nullable=False), + sa.Column('reg_end', sa.DateTime(), nullable=False), + sa.Column('max_team', sa.Integer(), nullable=False), + sa.Column('active_task_id', sa.Integer(), nullable=True), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('creator_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['active_task_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['status_id'], ['tournament_status_options.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('teams', + sa.Column('name', sa.String(), nullable=False), + sa.Column('team_email', sa.String(), nullable=False), + sa.Column('contact_info', sa.String(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.Column('captain_id', sa.Integer(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['captain_id'], ['team_members.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('contact_info'), + sa.UniqueConstraint('name'), + sa.UniqueConstraint('team_email') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('teams') + op.drop_table('tournaments') + op.drop_table('task_requirements') + op.drop_table('user_roles') + op.drop_table('tasks') + op.drop_table('task_requirement_options') + op.drop_table('users') + op.drop_table('tournament_status_options') + op.drop_table('team_members') + op.drop_table('task_statuses') + op.drop_table('task_requirement_categories') + op.drop_table('roles') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/b6b9a8bb78e1_.py b/backend/alembic/versions/b6b9a8bb78e1_.py new file mode 100644 index 0000000..f7bc1a3 --- /dev/null +++ b/backend/alembic/versions/b6b9a8bb78e1_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: b6b9a8bb78e1 +Revises: +Create Date: 2026-03-02 19:57:51.433308 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b6b9a8bb78e1' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_requirement_categories', sa.Column('main_id', sa.String(), nullable=True)) + op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['main_id'], ['name']) + op.drop_column('task_requirement_categories', 'category_id') + op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_options', 'task_requirement_categories', ['category_id'], ['name']) + op.alter_column('teams', 'captain_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_unique_constraint(None, 'teams', ['team_email']) + op.create_unique_constraint(None, 'teams', ['contact_info']) + op.drop_constraint(None, 'teams', type_='foreignkey') + op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'teams', 'tournaments', ['tournament_id'], ['id']) + op.alter_column('tournaments', 'active_task_id', + existing_type=sa.INTEGER(), + nullable=True) + op.add_column('users', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + op.drop_column('users', 'cleated_at') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) + op.drop_column('users', 'created_at') + op.alter_column('tournaments', 'active_task_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_constraint(None, 'teams', type_='foreignkey') + op.drop_constraint(None, 'teams', type_='foreignkey') + op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id']) + op.drop_constraint(None, 'teams', type_='unique') + op.drop_constraint(None, 'teams', type_='unique') + op.alter_column('teams', 'captain_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_options', 'task_statuses', ['category_id'], ['name']) + op.add_column('task_requirement_categories', sa.Column('category_id', sa.VARCHAR(), nullable=False)) + op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') + op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['category_id'], ['name']) + op.drop_column('task_requirement_categories', 'main_id') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 9cdf9b8..7628b2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiosqlite==0.22.1 +alembic==1.18.4 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 @@ -18,6 +19,7 @@ httpx==0.28.1 idna==3.11 iniconfig==2.3.0 Jinja2==3.1.6 +Mako==1.3.10 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 From 8b6378441db357b16a77bde75c827c2fbd4a93bd Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 20:05:27 +0200 Subject: [PATCH 024/369] chore: udate requirements.txt --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 7628b2d..bcb84b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,9 @@ alembic==1.18.4 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 +async-timeout==5.0.1 +asyncpg==0.31.0 +backports.asyncio.runner==1.2.0 certifi==2026.1.4 click==8.3.1 dnspython==2.8.0 From 87589e38b647220f37ae301f998ee2eb7e7f5954 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 22:00:43 +0200 Subject: [PATCH 025/369] feat(routes): impelent edit profile and delete profile --- backend/app/dependencies/__init__.py | 3 ++- backend/app/dependencies/current_user.py | 8 ++++++++ backend/app/dependencies/session.py | 2 +- backend/app/main.py | 4 +++- backend/app/routes/profile.py | 18 ++++++++++++++++++ backend/app/routes/tournaments.py | 6 +++--- backend/app/routes/users.py | 6 +++--- backend/app/schemas/__init__.py | 2 +- backend/app/schemas/user.py | 17 ++++++++++++----- backend/conftest.py | 9 +++++++++ backend/tests/routes/__init__.py | 0 backend/tests/routes/test_profile.py | 22 ++++++++++++++++++++++ 12 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 backend/app/dependencies/current_user.py create mode 100644 backend/app/routes/profile.py create mode 100644 backend/tests/routes/__init__.py create mode 100644 backend/tests/routes/test_profile.py diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py index 3801f16..4eb3da3 100644 --- a/backend/app/dependencies/__init__.py +++ b/backend/app/dependencies/__init__.py @@ -1 +1,2 @@ -from .session import Session \ No newline at end of file +from .current_user import CurrentUserDep, get_current_user +from .session import SessionDep, get_session \ No newline at end of file diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py new file mode 100644 index 0000000..5ac6278 --- /dev/null +++ b/backend/app/dependencies/current_user.py @@ -0,0 +1,8 @@ +from typing import Annotated +from fastapi import Depends +from app.models import User + +async def get_current_user(): + raise NotImplementedError('Authentication logic has to be implemented first!') + +CurrentUserDep = Annotated[User, Depends(get_current_user)] \ No newline at end of file diff --git a/backend/app/dependencies/session.py b/backend/app/dependencies/session.py index 6d5c778..6250186 100644 --- a/backend/app/dependencies/session.py +++ b/backend/app/dependencies/session.py @@ -3,4 +3,4 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -Session = Annotated[AsyncSession, Depends(get_session)] \ No newline at end of file +SessionDep = Annotated[AsyncSession, Depends(get_session)] \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 05071de..6c625ba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,9 @@ from fastapi import FastAPI import app.routes.tournaments as tournaments import app.routes.users as users +import app.routes.profile as profile app = FastAPI() app.include_router(tournaments.router) -app.include_router(users.router) \ No newline at end of file +app.include_router(users.router) +app.include_router(profile.router) \ No newline at end of file diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py new file mode 100644 index 0000000..0f6af75 --- /dev/null +++ b/backend/app/routes/profile.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, status +from app.schemas import UserUpdate, UserPublic +from app.dependencies import SessionDep, CurrentUserDep + +router = APIRouter(prefix='/profile', tags=['profile']) + +@router.patch('/', response_model=UserPublic) +async def edit_profile(session: SessionDep, current_user: CurrentUserDep, + user: UserUpdate): + user_data = user.model_dump(exclude_unset=True) + current_user.full_name = user_data['full_name'] + await session.commit() + return current_user + +@router.delete('/', status_code=status.HTTP_204_NO_CONTENT) +async def delete_profile(session: SessionDep, current_user: CurrentUserDep): + await session.delete(current_user) + await session.commit() \ No newline at end of file diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 1c9610a..16913f2 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -1,19 +1,19 @@ from fastapi import APIRouter, HTTPException, status from app.schemas import TournamentModels from app.models import Tournament -from app.dependencies import Session +from app.dependencies import SessionDep from sqlalchemy import select router = APIRouter(prefix='/tournaments', tags=['tournaments']) @router.get('/', response_model=list[TournamentModels]) -async def tournaments(session: Session): +async def tournaments(session: SessionDep): statement = select(Tournament) tournaments = await session.execute(statement) return tournaments.all() @router.get('/{tournament_id}/', response_model=TournamentModels) -async def tournament(tournament_id: int, session: Session): +async def tournament(tournament_id: int, session: SessionDep): statement = select(Tournament).where(Tournament.id==tournament_id) tournament = await session.execute(statement) if not tournament.first(): diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index 5ea26f1..769776a 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -1,19 +1,19 @@ from fastapi import APIRouter, HTTPException, status from app.schemas import UserModel from app.models import User -from app.dependencies import Session +from app.dependencies import SessionDep from sqlalchemy import select router = APIRouter(prefix='/users', tags=['users']) @router.get('/', response_model=list[UserModel]) -async def users(session: Session): +async def users(session: SessionDep): statement = select(User) users = await session.execute(statement) return users.all() @router.get('/{user_id}/', response_model=UserModel) -async def user(user_id: int, session: Session): +async def user(user_id: int, session: SessionDep): statement = select(User).where(User.id==user_id) user = await session.execute(statement) if not user.first(): diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d6fbfa0..62b7928 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,4 +2,4 @@ from .task import TaskModel from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel -from .user import UserModel +from .user import UserModel, UserPublic, UserUpdate diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3decf61..1d82257 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,11 +1,7 @@ from pydantic import BaseModel, Field, EmailStr, field_validator - -class UserModel(BaseModel): +class UserBase(BaseModel): full_name: str = Field(..., description="Username") - email: EmailStr = Field(..., description="User email") - password: str = Field(..., min_length=6, description="User password") - role: list[str] @field_validator("full_name") @classmethod @@ -14,6 +10,17 @@ def check_name(cls, value: str): raise ValueError("The name cannot be empty") return value +class UserUpdate(UserBase): + full_name: str | None = None + +class UserPublic(UserBase): + email: EmailStr + +class UserModel(UserBase): + email: EmailStr = Field(..., description="User email") + password: str = Field(..., min_length=6, description="User password") + role: list[str] + @field_validator("email") @classmethod def check_email(cls, value: str): diff --git a/backend/conftest.py b/backend/conftest.py index 3cf96ca..28bcf14 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,6 +1,9 @@ import pytest from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from app.main import app +from fastapi.testclient import TestClient +from app.dependencies import get_session from app.models import ( Base, @@ -34,8 +37,14 @@ async def setup_database(): @pytest.fixture async def db_session(): async with AsyncTestingSessionLocal() as session: + app.dependency_overrides[get_session] = lambda: session yield session + app.dependency_overrides.pop(get_session) +@pytest.fixture(scope='session') +async def client(): + client = TestClient(app) + yield client @pytest.fixture async def user(db_session): diff --git a/backend/tests/routes/__init__.py b/backend/tests/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/routes/test_profile.py b/backend/tests/routes/test_profile.py new file mode 100644 index 0000000..eee04fd --- /dev/null +++ b/backend/tests/routes/test_profile.py @@ -0,0 +1,22 @@ +import pytest +from app.main import app +from app.dependencies import get_current_user +from app.models import User + +async def test_update_profile(user, client): + full_name = 'John Doe' + app.dependency_overrides[get_current_user] = lambda: user + assert user.full_name != full_name + resp = client.patch('/profile/', json={ + 'full_name': full_name + }) + assert resp.json()['full_name'] == full_name + app.dependency_overrides.pop(get_current_user) + +async def test_delete_profile(user, client, db_session): + assert await db_session.get(User, user.id) is not None + app.dependency_overrides[get_current_user] = lambda: user + resp = client.delete('/profile/') + assert resp.status_code == 204 + assert await db_session.get(User, user.id) is None + app.dependency_overrides.pop(get_current_user) \ No newline at end of file From 606d2b1ae33aff92eb250cc4c3955a1f75101013 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 3 Mar 2026 21:15:04 +0200 Subject: [PATCH 026/369] feat(routing): Add basic react routing --- frontend/index.html | 3 +- frontend/package-lock.json | 60 ++++++++++++++++++- frontend/package.json | 3 +- frontend/public/vite.svg | 1 - frontend/src/App.tsx | 46 ++++++-------- frontend/src/assets/react.svg | 1 - frontend/src/pages/Home/Home.tsx | 19 ++++++ frontend/src/pages/Login/Login.tsx | 5 ++ frontend/src/pages/Profile/Profile.tsx | 5 ++ frontend/src/pages/Register/Register.tsx | 5 ++ .../pages/TournamentPage/TournamentPage.tsx | 5 ++ .../pages/TournamentsPage/TournamentsPage.tsx | 5 ++ 12 files changed, 124 insertions(+), 34 deletions(-) delete mode 100644 frontend/public/vite.svg delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/pages/Home/Home.tsx create mode 100644 frontend/src/pages/Login/Login.tsx create mode 100644 frontend/src/pages/Profile/Profile.tsx create mode 100644 frontend/src/pages/Register/Register.tsx create mode 100644 frontend/src/pages/TournamentPage/TournamentPage.tsx create mode 100644 frontend/src/pages/TournamentsPage/TournamentsPage.tsx diff --git a/frontend/index.html b/frontend/index.html index 072a57e..f4ac329 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,8 @@ - - frontend + Tournament
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 630dcc8..8322c66 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1965,6 +1966,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2863,6 +2877,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2934,6 +2986,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 991c982..210723e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..fb5ff2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,34 +1,24 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' import './App.css' +import {BrowserRouter, Route, Routes} from "react-router-dom"; +import Home from "./pages/Home/Home.tsx" +import TournamentsPage from "./pages/TournamentsPage/TournamentsPage.tsx"; +import TournamentPage from "./pages/TournamentPage/TournamentPage.tsx"; +import Profile from "./pages/Profile/Profile.tsx"; +import Login from "./pages/Login/Login.tsx"; +import Register from "./pages/Register/Register.tsx"; function App() { - const [count, setCount] = useState(0) - - return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- + return( + + + } /> + } /> + } /> + } /> + } /> + } /> + + ) } diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx new file mode 100644 index 0000000..2b56785 --- /dev/null +++ b/frontend/src/pages/Home/Home.tsx @@ -0,0 +1,19 @@ +function Home(){ + return ( + <> +

Сторінка для всього

+

Доступні сторінки:

+ +
+ "/" + "/tournaments" + "/tournament/:tournament_id" + "/profile" + "/login" + "/register" +
+ + ) +} + +export default Home; \ No newline at end of file diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx new file mode 100644 index 0000000..bc3e773 --- /dev/null +++ b/frontend/src/pages/Login/Login.tsx @@ -0,0 +1,5 @@ +function Login() { + return (

Сторінка для логіну

) +} + +export default Login; \ No newline at end of file diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..95593d6 --- /dev/null +++ b/frontend/src/pages/Profile/Profile.tsx @@ -0,0 +1,5 @@ +function Profile(){ + return (

Сторінка для профілю

) +} + +export default Profile; \ No newline at end of file diff --git a/frontend/src/pages/Register/Register.tsx b/frontend/src/pages/Register/Register.tsx new file mode 100644 index 0000000..db20774 --- /dev/null +++ b/frontend/src/pages/Register/Register.tsx @@ -0,0 +1,5 @@ +function Register(){ + return (

Сторінка для реестрації

) +} + +export default Register; \ No newline at end of file diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx new file mode 100644 index 0000000..7651841 --- /dev/null +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -0,0 +1,5 @@ +function TournamentPage(){ + return (

Сторінка для інфомації про конкретний тур

) +} + +export default TournamentPage; \ No newline at end of file diff --git a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx new file mode 100644 index 0000000..deef3e3 --- /dev/null +++ b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx @@ -0,0 +1,5 @@ +function TournamentsPage(){ + return (

Сторінка для всіх турнірів

) +} + +export default TournamentsPage; \ No newline at end of file From 8a17c32a7613ba0dcd8804b7af677b12bf1a0820 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 4 Mar 2026 17:11:50 +0200 Subject: [PATCH 027/369] feat(routers):Add 404 page and change Login to Auth --- .idea/.gitignore | 8 + .idea/Project.iml | 12 + .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + frontend/package-lock.json | 647 +++++++++++++++++-- frontend/package.json | 4 +- frontend/src/App.css | 1 + frontend/src/App.tsx | 8 +- frontend/src/pages/Auth/Auth.tsx | 5 + frontend/src/pages/Home/Home.tsx | 12 +- frontend/src/pages/Login/Login.tsx | 5 - frontend/src/pages/Page404/Page404.tsx | 39 ++ frontend/src/pages/Register/Register.tsx | 5 - frontend/vite.config.ts | 3 +- 16 files changed, 677 insertions(+), 98 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/Project.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 frontend/src/pages/Auth/Auth.tsx delete mode 100644 frontend/src/pages/Login/Login.tsx create mode 100644 frontend/src/pages/Page404/Page404.tsx delete mode 100644 frontend/src/pages/Register/Register.tsx diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Project.iml b/.idea/Project.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/Project.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..013246c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..74f1bcb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8322c66..7cb2f29 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@tailwindcss/vite": "^4.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.13.1", + "tailwindcss": "^4.2.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -316,7 +318,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -333,7 +334,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -350,7 +350,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -367,7 +366,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -384,7 +382,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -401,7 +398,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -418,7 +414,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -435,7 +430,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -452,7 +446,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -469,7 +462,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -486,7 +478,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -503,7 +494,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -520,7 +510,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -537,7 +526,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -554,7 +542,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -571,7 +558,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -588,7 +574,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -605,7 +590,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -622,7 +606,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -639,7 +622,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -656,7 +638,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -673,7 +654,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -690,7 +670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -707,7 +686,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -724,7 +702,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -741,7 +718,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -964,7 +940,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -975,7 +950,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -986,7 +960,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -996,14 +969,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1024,7 +995,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1038,7 +1008,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1052,7 +1021,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1066,7 +1034,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1080,7 +1047,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1094,7 +1060,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1108,7 +1073,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1122,7 +1086,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1136,7 +1099,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1150,7 +1112,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1164,7 +1125,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1178,7 +1138,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1192,7 +1151,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1206,7 +1164,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1220,7 +1177,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1234,7 +1190,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1248,7 +1203,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1262,7 +1216,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1276,7 +1229,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1290,7 +1242,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1304,7 +1255,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1318,7 +1268,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1332,7 +1281,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1346,7 +1294,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1360,13 +1307,269 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1416,7 +1619,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1430,7 +1632,7 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2026,6 +2228,15 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2033,11 +2244,23 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2307,7 +2530,6 @@ "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" @@ -2376,7 +2598,6 @@ "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, @@ -2423,6 +2644,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2517,6 +2744,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2608,6 +2844,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "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.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2641,6 +3126,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2665,7 +3159,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2781,14 +3274,12 @@ "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.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2801,7 +3292,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2929,7 +3419,6 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3019,7 +3508,6 @@ "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" @@ -3051,11 +3539,29 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3136,7 +3642,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -3184,7 +3690,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", diff --git a/frontend/package.json b/frontend/package.json index 210723e..776c2f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.13.1", + "tailwindcss": "^4.2.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..cff15dc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,4 @@ +@import "tailwindcss"; #root { max-width: 1280px; margin: 0 auto; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb5ff2d..022a57b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,8 @@ import Home from "./pages/Home/Home.tsx" import TournamentsPage from "./pages/TournamentsPage/TournamentsPage.tsx"; import TournamentPage from "./pages/TournamentPage/TournamentPage.tsx"; import Profile from "./pages/Profile/Profile.tsx"; -import Login from "./pages/Login/Login.tsx"; -import Register from "./pages/Register/Register.tsx"; +import Auth from "./pages/Auth/Auth.tsx"; +import Page404 from "./pages/Page404/Page404.tsx"; function App() { return( @@ -15,8 +15,8 @@ function App() { } /> } /> } /> - } /> - } /> + } /> + } /> ) diff --git a/frontend/src/pages/Auth/Auth.tsx b/frontend/src/pages/Auth/Auth.tsx new file mode 100644 index 0000000..d7bcc3c --- /dev/null +++ b/frontend/src/pages/Auth/Auth.tsx @@ -0,0 +1,5 @@ +function Auth() { + return (

Сторінка для логіну та реєстрації

) +} + +export default Auth; \ No newline at end of file diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 2b56785..09edc0f 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,17 +1,7 @@ function Home(){ return ( <> -

Сторінка для всього

-

Доступні сторінки:

- -
- "/" - "/tournaments" - "/tournament/:tournament_id" - "/profile" - "/login" - "/register" -
+

Головна сторінка

) } diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx deleted file mode 100644 index bc3e773..0000000 --- a/frontend/src/pages/Login/Login.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function Login() { - return (

Сторінка для логіну

) -} - -export default Login; \ No newline at end of file diff --git a/frontend/src/pages/Page404/Page404.tsx b/frontend/src/pages/Page404/Page404.tsx new file mode 100644 index 0000000..212d1a1 --- /dev/null +++ b/frontend/src/pages/Page404/Page404.tsx @@ -0,0 +1,39 @@ +import { Link, useNavigate } from 'react-router-dom'; + +function Page404() { + const navigate = useNavigate(); + + return ( +
+

+ 404 +

+ +
+

+ Схоже, ви загубилися... +

+

+ Ця сторінка або пішла у відпустку, або її ніколи не існувало. + Не хвилюйтеся, ми допоможемо вам повернутися. +

+
+
+ + +
+
+ ); +}; + +export default Page404; \ No newline at end of file diff --git a/frontend/src/pages/Register/Register.tsx b/frontend/src/pages/Register/Register.tsx deleted file mode 100644 index db20774..0000000 --- a/frontend/src/pages/Register/Register.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function Register(){ - return (

Сторінка для реестрації

) -} - -export default Register; \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..c4069b7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], }) From 1496ec71b0e279f0033c9940ea2f5a5817d4585a Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Wed, 4 Mar 2026 16:55:03 +0000 Subject: [PATCH 028/369] refactor: move conftest to tests folder and update team model --- backend/app/models/team.py | 3 + backend/conftest.py | 134 ------------------------------------- backend/tests/conftest.py | 52 ++++++++++++++ 3 files changed, 55 insertions(+), 134 deletions(-) delete mode 100644 backend/conftest.py create mode 100644 backend/tests/conftest.py diff --git a/backend/app/models/team.py b/backend/app/models/team.py index bf58c42..b26d9db 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -22,6 +22,9 @@ class Team(Base, PKMixin): foreign_keys="TeamMember.team_id", cascade="all, delete-orphan", ) + captain: Mapped["TeamMember"] = relationship( + "TeamMember", foreign_keys="Team.captain_id", post_update=True + ) def __repr__(self): return f"" diff --git a/backend/conftest.py b/backend/conftest.py deleted file mode 100644 index 3cf96ca..0000000 --- a/backend/conftest.py +++ /dev/null @@ -1,134 +0,0 @@ -import pytest -from datetime import datetime, timedelta -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker - -from app.models import ( - Base, - User, - Role, - Team, - TeamMember, - Tournament, - TournamentStatusOption, - Task, -) - -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" - -engine = create_async_engine( - TEST_DATABASE_URL, -) - -AsyncTestingSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False) - - -@pytest.fixture(scope="function", autouse=True) -async def setup_database(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - - -@pytest.fixture -async def db_session(): - async with AsyncTestingSessionLocal() as session: - yield session - - -@pytest.fixture -async def user(db_session): - user = User( - full_name="Test User", - email="test@example.com", - password="very_strong_password", - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - return user - - -@pytest.fixture -async def role(db_session): - role = Role(name="jury") - db_session.add(role) - await db_session.commit() - await db_session.refresh(role) - return role - - -@pytest.fixture -async def tournament_status(db_session): - status = TournamentStatusOption(name="Registration Open") - db_session.add(status) - await db_session.commit() - await db_session.refresh(status) - return status - - -@pytest.fixture -async def tournament(db_session, user, tournament_status): - tournament = Tournament( - title="Test Tournament", - description="Test Description", - start_date=datetime.now() + timedelta(days=10), - reg_start=datetime.now(), - reg_end=datetime.now() + timedelta(days=5), - max_team=50, - status_id=tournament_status.id, - creator_id=user.id, - active_task_id=None, - ) - db_session.add(tournament) - await db_session.commit() - await db_session.refresh(tournament) - return tournament - - -@pytest.fixture -async def task(db_session, tournament): - task = Task( - title="Test Title", - description="Test Description", - tournament_id=tournament.id, - start_time=datetime.now(), - end_time=datetime.now() + timedelta(hours=2), - status_id="draft", - ) - - db_session.add(task) - await db_session.commit() - await db_session.refresh(task) - return task - - -@pytest.fixture -async def team(db_session, user, tournament): - team = Team( - name="Test Team", - team_email="test@example.com", - contact_info="0680000000", - tournament_id=tournament.id, - captain_id=user.id, - ) - db_session.add(team) - await db_session.commit() - await db_session.refresh(team) - return team - - -@pytest.fixture -async def team_member(db_session, team): - team_member = TeamMember( - full_name="Test Name", - email="test@example.com", - telegram_username="@test_username", - educational_institution="Test Location", - team_id=team.id, - ) - db_session.add(team_member) - await db_session.commit() - await db_session.refresh(team_member) - return team_member diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5d7c4c1 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,52 @@ +import pytest +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from app.models import Base +from .factories import ( + UserFactory, + RoleFactory, + TournamentStatusOptionFactory, + TournamentFactory, + TeamFactory, + TeamMemberFactory, +) + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +engine = create_async_engine( + TEST_DATABASE_URL, +) + +AsyncTestingSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False) + + +@pytest.fixture(scope="function", autouse=True) +async def setup_database(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +async def db_session(): + async with AsyncTestingSessionLocal() as session: + yield session + + +@pytest.fixture(autouse=True) +async def setup_factories(db_session): + factories = [ + UserFactory, + RoleFactory, + TeamFactory, + TeamMemberFactory, + TournamentFactory, + TournamentStatusOptionFactory, + ] + for f in factories: + f._meta.sqlalchemy_session = db_session + yield + for f in factories: + f._meta.sqlalchemy_session = None From 08986c53c62bbb2d29ed9990acb129c441c7f1b2 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Wed, 4 Mar 2026 19:41:53 +0000 Subject: [PATCH 029/369] rewrote part of the tests --- backend/tests/conftest.py | 14 +- backend/tests/factories.py | 90 +++++++++++ backend/tests/test_models.py | 293 +++++++++++++++++------------------ 3 files changed, 244 insertions(+), 153 deletions(-) create mode 100644 backend/tests/factories.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5d7c4c1..4ed1144 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -29,7 +29,7 @@ async def setup_database(): await conn.run_sync(Base.metadata.drop_all) -@pytest.fixture +@pytest.fixture(scope="function", autouse=True) async def db_session(): async with AsyncTestingSessionLocal() as session: yield session @@ -50,3 +50,15 @@ async def setup_factories(db_session): yield for f in factories: f._meta.sqlalchemy_session = None + + +@pytest.fixture +async def create(db_session): + async def _create(factory_class, **kwargs): + obj = factory_class.build(**kwargs) + db_session.add(obj) + await db_session.commit() + await db_session.refresh(obj) + return obj + + return _create diff --git a/backend/tests/factories.py b/backend/tests/factories.py new file mode 100644 index 0000000..65a44c7 --- /dev/null +++ b/backend/tests/factories.py @@ -0,0 +1,90 @@ +import factory +from factory.faker import Faker +from factory.alchemy import SQLAlchemyModelFactory + +from app.models import ( + User, + Role, + TeamMember, + Team, + Tournament, + TournamentStatusOption, + Task, + TaskRequirementCategory, + TaskRequirementOption, +) + + +class BaseFactory(SQLAlchemyModelFactory): + class Meta: + abstract = True + sqlalchemy_session = None + sqlalchemy_session_persistence = None + + +class UserFactory(BaseFactory): + class Meta: + model = User + + full_name = Faker("name") + email = "test@example.com" + password = "very_strong_password" + + +class RoleFactory(BaseFactory): + class Meta: + model = Role + + name = factory.Iterator(["admin", "user", "jury"]) + + +class TournamentStatusOptionFactory(BaseFactory): + class Meta: + model = TournamentStatusOption + + name = factory.Iterator(["Registration Open", "Ongoing", "Finished"]) + + +class TournamentFactory(BaseFactory): + class Meta: + model = Tournament + + title = Faker("catch_phrase") + description = Faker("paragraph") + start_date = Faker("future_datetime") + reg_start = Faker("past_datetime") + reg_end = Faker("future_datetime") + max_team = Faker("pyint", min_value=10, max_value=100) + + creator = factory.SubFactory(UserFactory) + status = factory.SubFactory(TournamentStatusOptionFactory) + + +class TeamFactory(BaseFactory): + class Meta: + model = Team + + name = Faker("name") + team_email = Faker("email") + contact_info = Faker("phone_number") + + tournament = factory.SubFactory(TournamentFactory) + captain = None + + @classmethod + def with_captain(cls, **kwargs): + team = cls.build(**kwargs) + captain = TeamMemberFactory.build(team=team) + team.captain = captain + return team, captain + + +class TeamMemberFactory(BaseFactory): + class Meta: + model = TeamMember + + full_name = Faker("name") + email = Faker("email") + telegram_username = factory.Sequence(lambda n: f"@user_{n}") + educational_institution = Faker("company") + team = factory.SubFactory(TeamFactory) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index d729468..8b7f3d1 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -13,22 +13,29 @@ TaskRequirementOption, ) +from .factories import ( + UserFactory, + RoleFactory, + TournamentStatusOptionFactory, + TournamentFactory, + TeamFactory, + TeamMemberFactory, +) -async def test_create_user(user): +async def test_create_user(create): + user = await create(UserFactory) assert user.id is not None - assert user.full_name == "Test User" - assert user.email == "test@example.com" + assert user.full_name is not None + assert user.email is not None assert user.created_at is not None -async def test_create_user_duplicate_email(db_session, user): - user2 = User( - full_name="Petro Petrov", - email=user.email, - password="very_strong_password", - ) - db_session.add(user2) +async def test_create_user_duplicate_email(db_session, create): + user1 = await create(UserFactory) + + duplicate_user = UserFactory.build(email=user1.email) + db_session.add(duplicate_user) with pytest.raises(IntegrityError): await db_session.flush() @@ -36,84 +43,67 @@ async def test_create_user_duplicate_email(db_session, user): await db_session.rollback() -async def test_create_user_without_password(db_session): - - user = User( - full_name="No Password", - email="nopass@example.com", - ) - db_session.add(user) - +async def test_create_user_without_password(create): with pytest.raises(IntegrityError): - await db_session.flush() - - await db_session.rollback() + await create(UserFactory, password=None) -async def test_create_role(role): - +async def test_create_role(create): + role = await create(RoleFactory, name="jury") assert role.id is not None assert role.name == "jury" -async def test_role_name_unique_constraint(db_session, role): - - role_duplicate = Role(name="jury") - db_session.add(role_duplicate) +async def test_role_name_unique_constraint(create): + await create(RoleFactory, name="jury") with pytest.raises(IntegrityError): - await db_session.flush() - - await db_session.rollback() + await create(RoleFactory, name="jury") -async def test_user_roles_relationship(db_session, user, role): - admin_role = Role(name="admin") - db_session.add(admin_role) - - await db_session.refresh(user, attribute_names=["roles"]) - user.roles.extend([role, admin_role]) - - await db_session.flush() +async def test_user_roles_relationship(db_session, create): + user = await create(UserFactory) + role_jury = await create(RoleFactory, name="jury") + role_admin = await create(RoleFactory, name="admin") stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) result = await db_session.execute(stmt) + user = result.unique().scalar_one() - db_user = result.unique().scalar_one() + user.roles.extend([role_jury, role_admin]) + await db_session.commit() - assert len(db_user.roles) == 2 + stmt_check = ( + select(User).where(User.id == user.id).options(selectinload(User.roles)) + ) + result_check = await db_session.execute(stmt_check) + db_user = result_check.unique().scalar_one() - role_names = [r.name for r in db_user.roles] - assert "admin" in role_names - assert "jury" in role_names + assert len(db_user.roles) == 2 + assert "admin" in [r.name for r in db_user.roles] -async def test_create_team_member(team, team_member): +async def test_create_team_member(create): + member = await create(TeamMemberFactory) - assert team_member.id is not None - assert team_member.team_id == team.id + assert member.id is not None + assert member.team_id == member.team.id async def test_team_member_without_data(db_session): member = TeamMember() - db_session.add(member) with pytest.raises(IntegrityError): await db_session.flush() - await db_session.rollback() -async def test_team_member_duplicate_email(db_session, team, team_member): - duplicate = TeamMember( - full_name="Test Name", - email=team_member.email, - telegram_username="@test2", - educational_institution="Test School", - team_id=team.id, - ) +async def test_team_member_duplicate_email(create, db_session): + team = await create(TeamFactory) + member1 = await create(TeamMemberFactory, team=team) + duplicate = TeamMemberFactory.build(email=member1.email, team=team) db_session.add(duplicate) with pytest.raises(IntegrityError): @@ -122,158 +112,157 @@ async def test_team_member_duplicate_email(db_session, team, team_member): await db_session.rollback() -async def test_create_team(team): - +async def test_create_team(create): + team = await create(TeamFactory) assert team.id is not None - assert team.name == "Test Team" - assert team.team_email == "test@example.com" - assert team.contact_info == "0680000000" + assert team.name is not None + assert team.team_email is not None async def test_team_without_data(db_session): - team = Team() - - db_session.add(team) - + member = TeamMember() + db_session.add(member) with pytest.raises(IntegrityError): await db_session.flush() await db_session.rollback() -async def test_team_duplicate_email(db_session, team, tournament): - duplicate_team = Team( - name="Test Name", - team_email=team.team_email, - contact_info="0680000001", - tournament_id=tournament.id, - ) - - db_session.add(duplicate_team) +async def test_team_duplicate_email(db_session, create): + await create(TeamFactory) with pytest.raises(IntegrityError): - await db_session.flush() + await create(TeamFactory) await db_session.rollback() -async def test_set_team_captain(db_session, team, team_member): - team.captain_id = team_member.id +async def test_set_team_captain(db_session, create): + team = await create(TeamFactory) + member = await create(TeamMemberFactory, team=team) + team.captain_id = member.id db_session.add(team) await db_session.commit() - await db_session.refresh(team) - assert team.captain_id == team_member.id + assert team.captain_id == member.id + + assert team.captain_id == member.id + + assert team.captain_id == member.id + + +async def test_team_team_member_relationship(db_session, create): + team = await create(TeamFactory) + member = await create(TeamMemberFactory, team=team) -async def test_team_team_member_relationship(db_session, team, team_member): stmt = select(Team).where(Team.id == team.id).options(selectinload(Team.members)) result = await db_session.execute(stmt) - db_team = result.unique().scalar_one() assert len(db_team.members) == 1 - assert db_team.members[0].id == team_member.id + assert db_team.members[0].id == member.id -async def test_team_cascade_delete_members(db_session, team, team_member): - member_id = team_member.id +async def test_team_cascade_delete_members(db_session, create): + team = await create(TeamFactory) + member = await create(TeamMemberFactory, team=team) + member_id = member.id await db_session.delete(team) await db_session.commit() stmt = select(TeamMember).where(TeamMember.id == member_id) result = await db_session.execute(stmt) - assert result.scalar_one_or_none() is None -async def test_create_task(task, tournament): +# async def test_create_task(task, tournament): - assert task.id is not None - assert task.title == "Test Title" - assert task.tournament_id == tournament.id - assert task.status_id == "draft" +# assert task.id is not None +# assert task.title == "Test Title" +# assert task.tournament_id == tournament.id +# assert task.status_id == "draft" -async def test_task_requirements_relationship(db_session, task): +# async def test_task_requirements_relationship(db_session, task): - category = TaskRequirementCategory( - name="Test Category", display_name="Test Category", main_id="Test Category" - ) - option = TaskRequirementOption( - name="Test Option", display_name="Test Option", category=category - ) - db_session.add_all([category, option]) - await db_session.flush() +# category = TaskRequirementCategory( +# name="Test Category", display_name="Test Category", main_id="Test Category" +# ) +# option = TaskRequirementOption( +# name="Test Option", display_name="Test Option", category=category +# ) +# db_session.add_all([category, option]) +# await db_session.flush() - await db_session.refresh(task, attribute_names=["requirements"]) +# await db_session.refresh(task, attribute_names=["requirements"]) - task.requirements.append(option) - await db_session.commit() +# task.requirements.append(option) +# await db_session.commit() - stmt = ( - select(Task).where(Task.id == task.id).options(selectinload(Task.requirements)) - ) +# stmt = ( +# select(Task).where(Task.id == task.id).options(selectinload(Task.requirements)) +# ) - result = await db_session.execute(stmt) - db_task = result.unique().scalar_one() +# result = await db_session.execute(stmt) +# db_task = result.unique().scalar_one() - assert len(db_task.requirements) == 1 - assert db_task.requirements[0].name == "Test Option" +# assert len(db_task.requirements) == 1 +# assert db_task.requirements[0].name == "Test Option" -async def test_task_without_data(db_session): - task = Task() +# async def test_task_without_data(db_session): +# task = Task() - db_session.add(task) +# db_session.add(task) - with pytest.raises(IntegrityError): - await db_session.flush() +# with pytest.raises(IntegrityError): +# await db_session.flush() - await db_session.rollback() +# await db_session.rollback() -async def test_task_invalid_time(db_session, tournament): - task = Task( - title="Test Task", - description="...", - tournament_id=tournament.id, - start_time=datetime.now(), - end_time=datetime.now() - timedelta(hours=1), - status_id="draft", - ) - db_session.add(task) +# async def test_task_invalid_time(db_session, tournament): +# task = Task( +# title="Test Task", +# description="...", +# tournament_id=tournament.id, +# start_time=datetime.now(), +# end_time=datetime.now() - timedelta(hours=1), +# status_id="draft", +# ) +# db_session.add(task) - await db_session.commit() - assert task.end_time < task.start_time +# await db_session.commit() +# assert task.end_time < task.start_time -async def test_task_category_hierarchy(db_session): - parent = TaskRequirementCategory( - name="Programming", display_name="Programming Languages" - ) - db_session.add(parent) - await db_session.flush() - - child = TaskRequirementCategory( - name="Python", - display_name="Python Language", - main_id="Programming", - parent_category=parent, - ) - db_session.add(child) - await db_session.commit() +# async def test_task_category_hierarchy(db_session): +# parent = TaskRequirementCategory( +# name="Programming", display_name="Programming Languages" +# ) +# db_session.add(parent) +# await db_session.flush() - stmt = ( - select(TaskRequirementCategory) - .where(TaskRequirementCategory.name == "Programming") - .options(selectinload(TaskRequirementCategory.sub_categories)) - ) +# child = TaskRequirementCategory( +# name="Python", +# display_name="Python Language", +# main_id="Programming", +# parent_category=parent, +# ) +# db_session.add(child) +# await db_session.commit() - result = await db_session.execute(stmt) - db_parent = result.unique().scalar_one() +# stmt = ( +# select(TaskRequirementCategory) +# .where(TaskRequirementCategory.name == "Programming") +# .options(selectinload(TaskRequirementCategory.sub_categories)) +# ) + +# result = await db_session.execute(stmt) +# db_parent = result.unique().scalar_one() - assert len(db_parent.sub_categories) == 1 - assert db_parent.sub_categories[0].name == "Python" - assert db_parent.sub_categories[0].parent_category.name == "Programming" +# assert len(db_parent.sub_categories) == 1 +# assert db_parent.sub_categories[0].name == "Python" +# assert db_parent.sub_categories[0].parent_category.name == "Programming" From a3035b8861e09aaf1e90eb59719b8356e7eadec9 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sat, 7 Mar 2026 15:06:09 +0200 Subject: [PATCH 030/369] feat(tournamentPage): Create tournament page with css styles --- frontend/src/App.tsx | 3 +- frontend/src/main.tsx | 3 +- .../pages/TournamentPage/TournamentPage.css | 326 ++++++++++++++++++ .../pages/TournamentPage/TournamentPage.tsx | 120 ++++++- frontend/src/reset.css | 54 +++ 5 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/TournamentPage/TournamentPage.css create mode 100644 frontend/src/reset.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 022a57b..f595e51 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import './App.css' +//import './App.css' +import "./reset.css" import {BrowserRouter, Route, Routes} from "react-router-dom"; import Home from "./pages/Home/Home.tsx" import TournamentsPage from "./pages/TournamentsPage/TournamentsPage.tsx"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..8fdb95b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './index.css' +//import './index.css' +import './reset.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/TournamentPage/TournamentPage.css b/frontend/src/pages/TournamentPage/TournamentPage.css new file mode 100644 index 0000000..38562b2 --- /dev/null +++ b/frontend/src/pages/TournamentPage/TournamentPage.css @@ -0,0 +1,326 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + background-color: #f9fafc; + font-family: 'Inter', 'Montserrat', sans-serif; +} +header { + position: absolute; + top: 0; + left: 0; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 5%; + color: white; + z-index: 10; +} + +header .sflu { + font-size: 22px; + font-weight: 700; + margin: 0; + width: 200px; +} + +.nav-menu { + display: flex; + gap: 30px; + justify-content: center; + flex-grow: 1; +} + +.nav-menu button { + background: transparent; + border: none; + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; +} + +.nav-menu button:hover { + opacity: 1; +} + +.information { + background-color: #6366f1; + padding-top: 120px; + padding-bottom: 150px; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + color: white; + overflow: hidden; +} + +.status-theme { + display: flex; + flex-direction: row; + justify-content: center; + margin-bottom: 20px; +} + +.status, .theme { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 6px 16px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + margin: 0 5px 20px 5px; +} + +.status { + background-color: #facc15; + color: #111827; +} + +.theme { + background-color: rgba(255, 255, 255, 0.2); + color: white; +} + +.tournament-name { + font-size: 56px; + font-weight: 700; + margin: 0 0 30px 0; + letter-spacing: -1px; +} + +.conditions { + display: flex; + gap: 50px; + margin-bottom: 40px; +} + +.condition { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} + +.condition p:first-child { + color: #facc15; + font-size: 20px; + font-weight: 700; + margin: 0; +} + +.condition p:last-child { + font-size: 13px; + opacity: 0.9; + margin: 0; +} + +.control-buttons { + display: flex; + gap: 15px; + z-index: 2; +} + +.control-buttons button { + padding: 12px 30px; + border-radius: 30px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.3s ease; +} + +.control-buttons .submit { + background-color: #facc15; + color: #111827; +} + +.control-buttons .submit:hover { + background-color: #eab308; +} + +.control-buttons .find-team { + background-color: transparent; + color: white; + border: 1px solid rgba(255, 255, 255, 0.5); +} + +.control-buttons .find-team:hover { + background-color: rgba(255, 255, 255, 0.1); + border-color: white; +} + +.information svg { + position: absolute; + bottom: -2px; + left: 0; + width: 100%; + height: auto; + z-index: 1; +} + +.information svg path { + fill: #f9fafc !important; +} + +.about-tournament { + background-color: #f9fafc; + padding: 20px 20px 80px 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.tabs { + display: flex; + gap: 40px; + margin-bottom: 40px; +} + +.tabs button { + background: transparent; + border: none; + font-size: 16px; + font-weight: 600; + color: #6b7280; + cursor: pointer; + padding: 0 0 10px 0; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; +} + +.tabs button:hover { + color: #374151; +} + +.tabs button.active { + color: #6366f1; + border-bottom: 2px solid #6366f1; +} + +.main-information { + background-color: #ffffff; + border-radius: 24px; + padding: 50px 60px; + width: 100%; + max-width: 850px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04); +} + +.main-information h2 { + font-size: 22px; + font-weight: 700; + color: #111827; + margin: 35px 0 15px 0; +} + +.main-information h2:first-child { + margin-top: 0; +} + +.main-information p { + font-size: 15px; + line-height: 1.6; + color: #4b5563; + margin: 0; +} + + +.main-information ul { + margin: 0; + padding-left: 20px; + color: #4b5563; +} + +.main-information li { + font-size: 15px; + line-height: 1.6; + margin-bottom: 8px; +} + + +@media (max-width: 768px) { + header { + flex-direction: column; + gap: 15px; + padding: 15px; + } + + header .sflu { + text-align: center; + width: 100%; + } + + .nav-menu { + flex-wrap: wrap; + gap: 15px; + justify-content: center; + } + + .information { + padding-top: 140px; + padding-bottom: 100px; + } + + .tournament-name { + font-size: 32px; + margin-bottom: 20px; + } + .conditions { + flex-direction: column; + gap: 20px; + margin-bottom: 30px; + } + + .control-buttons { + flex-direction: column; + width: 90%; + max-width: 350px; + } + + .control-buttons button { + width: 100%; + } + + .information svg { + height: 60px; + } + + .about-tournament { + padding: 20px 15px 50px 15px; + } + .tabs { + width: 100%; + overflow-x: auto; + white-space: nowrap; + justify-content: flex-start; + gap: 20px; + padding-bottom: 10px; + } + + .tabs::-webkit-scrollbar { + display: none; + } + .main-information { + padding: 30px 20px; + } + + .main-information h2 { + font-size: 18px; + } + + .main-information p, .main-information li { + font-size: 14px; + } +} \ No newline at end of file diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx index 7651841..eefaae2 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -1,5 +1,121 @@ -function TournamentPage(){ - return (

Сторінка для інфомації про конкретний тур

) +import { useState } from 'react'; +import "./TournamentPage.css"; + +const tournamentInfo = { + status: "Активно", + theme: "Програмування", + description: "Ваша мета — створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс,", + requirements: [ + "створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс,", + "створити ядро аналог лінукс, створити ядро аналог лінукс,", + "створити ядро аналог лінукс,", + "створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс," + ], + techStack: { + limitations: "Жодних обмежень!", + details: "створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс," + } +}; + +function TournamentPage() { + const [activeTab, setActiveTab] = useState('description'); + + return ( + <> +
+

Star For Life Ukraine

+
+ + + +
+
+ +
+
+
{tournamentInfo.status}
+
{tournamentInfo.theme}
+
+ +

Slovo Game Jam

+ +
+
+

12 днів

+

До кінця

+
+
+

Від 3 до 5

+

Учасників в команді

+
+
+

3

+

Призових місць

+
+
+ +
+ + +
+ + + + +
+ +
+
+ + + + +
+ + {activeTab === 'description' && ( +
+

Що потрібно зробити?

+

{tournamentInfo.description}

+ +

Ключові вимоги:

+
    + {tournamentInfo.requirements.map((req, index) => ( +
  • {req}
  • + ))} +
+ +

Стек технологій:

+

{tournamentInfo.techStack.limitations}
{tournamentInfo.techStack.details}

+
+ )} +
+ + ) } export default TournamentPage; \ No newline at end of file diff --git a/frontend/src/reset.css b/frontend/src/reset.css new file mode 100644 index 0000000..9bda3a6 --- /dev/null +++ b/frontend/src/reset.css @@ -0,0 +1,54 @@ +/* http://meyerweb.com/eric/tools/css/reset/ */ +/* v1.0 | 20080212 */ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, font, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 100%; + vertical-align: baseline; + background: transparent; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +/* remember to define focus styles! */ +:focus { + outline: 0; +} + +/* remember to highlight inserts somehow! */ +ins { + text-decoration: none; +} +del { + text-decoration: line-through; +} + +/* tables still need 'cellspacing="0"' in the markup */ +table { + border-collapse: collapse; + border-spacing: 0; +} + From 140916084ea3c3bdf0f283153e2d4d06937b7a57 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 7 Mar 2026 14:40:06 +0000 Subject: [PATCH 031/369] added new factories and rewrote some tests --- backend/tests/conftest.py | 8 ++++-- backend/tests/factories.py | 23 ++++++++++++++++- backend/tests/test_models.py | 49 ++++++++++++++++-------------------- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4ed1144..3000e90 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,8 @@ TournamentFactory, TeamFactory, TeamMemberFactory, + TaskStatusOptionFactory, + TaskFactory, ) TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -20,7 +22,7 @@ AsyncTestingSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False) -@pytest.fixture(scope="function", autouse=True) +@pytest.fixture(autouse=True) async def setup_database(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -29,7 +31,7 @@ async def setup_database(): await conn.run_sync(Base.metadata.drop_all) -@pytest.fixture(scope="function", autouse=True) +@pytest.fixture() async def db_session(): async with AsyncTestingSessionLocal() as session: yield session @@ -44,6 +46,8 @@ async def setup_factories(db_session): TeamMemberFactory, TournamentFactory, TournamentStatusOptionFactory, + TaskStatusOptionFactory, + TaskFactory, ] for f in factories: f._meta.sqlalchemy_session = db_session diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 65a44c7..1d0236b 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -10,8 +10,9 @@ Tournament, TournamentStatusOption, Task, - TaskRequirementCategory, + TaskStatusOption, TaskRequirementOption, + TaskRequirementCategory, ) @@ -88,3 +89,23 @@ class Meta: telegram_username = factory.Sequence(lambda n: f"@user_{n}") educational_institution = Faker("company") team = factory.SubFactory(TeamFactory) + + +class TaskStatusOptionFactory(BaseFactory): + class Meta: + model = TaskStatusOption + + name = factory.Iterator(["draft", "active", "finished"]) + display_name = factory.LazyAttribute(lambda f: f.name.upper()) + + +class TaskFactory(BaseFactory): + class Meta: + model = Task + + title = Faker("catch_phrase") + description = Faker("paragraph") + start_time = Faker("future_datetime") + end_time = Faker("future_datetime") + tournament = factory.SubFactory(TournamentFactory) + status = factory.SubFactory(TaskStatusOptionFactory) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 8b7f3d1..f477082 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -20,6 +20,8 @@ TournamentFactory, TeamFactory, TeamMemberFactory, + TaskStatusOptionFactory, + TaskFactory, ) @@ -177,15 +179,16 @@ async def test_team_cascade_delete_members(db_session, create): assert result.scalar_one_or_none() is None -# async def test_create_task(task, tournament): +async def test_create_task(create): + task = await create(TaskFactory) -# assert task.id is not None -# assert task.title == "Test Title" -# assert task.tournament_id == tournament.id -# assert task.status_id == "draft" + assert task.id is not None + assert task.title is not None + assert task.tournament_id is not None + assert task.status_id in ["draft", "active", "finished"] -# async def test_task_requirements_relationship(db_session, task): +# async def test_task_requirements_relationship(db_session, create): # category = TaskRequirementCategory( # name="Test Category", display_name="Test Category", main_id="Test Category" @@ -212,30 +215,22 @@ async def test_team_cascade_delete_members(db_session, create): # assert db_task.requirements[0].name == "Test Option" -# async def test_task_without_data(db_session): -# task = Task() - -# db_session.add(task) - -# with pytest.raises(IntegrityError): -# await db_session.flush() - -# await db_session.rollback() +async def test_task_without_data(db_session): + task = Task() + db_session.add(task) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() -# async def test_task_invalid_time(db_session, tournament): -# task = Task( -# title="Test Task", -# description="...", -# tournament_id=tournament.id, -# start_time=datetime.now(), -# end_time=datetime.now() - timedelta(hours=1), -# status_id="draft", -# ) -# db_session.add(task) +async def test_task_invalid_time(db_session, create): + task = await create( + TaskFactory, + start_time=datetime.now(), + end_time=datetime.now() - timedelta(hours=1), + ) -# await db_session.commit() -# assert task.end_time < task.start_time + assert task.end_time < task.start_time # async def test_task_category_hierarchy(db_session): From 649f99a9b068893cacbd81dafdf7004c132b6260 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sun, 8 Mar 2026 19:53:51 +0000 Subject: [PATCH 032/369] minor changes --- backend/app/models/task.py | 4 +- backend/tests/conftest.py | 33 +------------- backend/tests/factories.py | 18 ++++++++ backend/tests/test_models.py | 84 +++++++++++++----------------------- 4 files changed, 53 insertions(+), 86 deletions(-) diff --git a/backend/app/models/task.py b/backend/app/models/task.py index c571869..04aa5ec 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -54,7 +54,9 @@ class TaskRequirementOption(Base, OptionMixin): class TaskRequirementCategory(Base, OptionMixin): __tablename__ = "task_requirement_categories" - main_id: Mapped[str] = mapped_column(ForeignKey("task_requirement_categories.name"), nullable=True) + main_id: Mapped[str] = mapped_column( + ForeignKey("task_requirement_categories.name"), nullable=True + ) sub_categories: Mapped[list["TaskRequirementCategory"]] = relationship( back_populates="parent_category" ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3000e90..9a87185 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,16 +2,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from app.models import Base -from .factories import ( - UserFactory, - RoleFactory, - TournamentStatusOptionFactory, - TournamentFactory, - TeamFactory, - TeamMemberFactory, - TaskStatusOptionFactory, - TaskFactory, -) + TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -37,32 +28,12 @@ async def db_session(): yield session -@pytest.fixture(autouse=True) -async def setup_factories(db_session): - factories = [ - UserFactory, - RoleFactory, - TeamFactory, - TeamMemberFactory, - TournamentFactory, - TournamentStatusOptionFactory, - TaskStatusOptionFactory, - TaskFactory, - ] - for f in factories: - f._meta.sqlalchemy_session = db_session - yield - for f in factories: - f._meta.sqlalchemy_session = None - - @pytest.fixture async def create(db_session): async def _create(factory_class, **kwargs): obj = factory_class.build(**kwargs) db_session.add(obj) - await db_session.commit() - await db_session.refresh(obj) + await db_session.flush() return obj return _create diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 1d0236b..6899fe9 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -109,3 +109,21 @@ class Meta: end_time = Faker("future_datetime") tournament = factory.SubFactory(TournamentFactory) status = factory.SubFactory(TaskStatusOptionFactory) + + +class TaskRequirementCategoryFactory(BaseFactory): + class Meta: + model = TaskRequirementCategory + + name = factory.Sequence(lambda n: f"category_{n}") + display_name = factory.LazyAttribute(lambda f: f.name.upper()) + + +class TaskRequirementOptionFactory(BaseFactory): + class Meta: + model = TaskRequirementOption + + name = factory.Sequence(lambda n: f"category_{n}") + display_name = factory.LazyAttribute(lambda f: f.name.upper()) + + category = factory.SubFactory(TaskRequirementCategoryFactory) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index f477082..b6503fa 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -5,7 +5,6 @@ from sqlalchemy.orm import selectinload from app.models import ( User, - Role, TeamMember, Team, Task, @@ -22,6 +21,8 @@ TeamMemberFactory, TaskStatusOptionFactory, TaskFactory, + TaskRequirementCategoryFactory, + TaskRequirementOptionFactory, ) @@ -148,9 +149,7 @@ async def test_set_team_captain(db_session, create): await db_session.refresh(team) assert team.captain_id == member.id - assert team.captain_id == member.id - assert team.captain_id == member.id @@ -188,31 +187,20 @@ async def test_create_task(create): assert task.status_id in ["draft", "active", "finished"] -# async def test_task_requirements_relationship(db_session, create): - -# category = TaskRequirementCategory( -# name="Test Category", display_name="Test Category", main_id="Test Category" -# ) -# option = TaskRequirementOption( -# name="Test Option", display_name="Test Option", category=category -# ) -# db_session.add_all([category, option]) -# await db_session.flush() +async def test_task_requirements_relationship(db_session, create): + category = await create(TaskRequirementCategoryFactory) + option = await create(TaskRequirementOptionFactory, category=category) + task = await create(TaskFactory, requirements=[option]) -# await db_session.refresh(task, attribute_names=["requirements"]) - -# task.requirements.append(option) -# await db_session.commit() - -# stmt = ( -# select(Task).where(Task.id == task.id).options(selectinload(Task.requirements)) -# ) + stmt = ( + select(Task).where(Task.id == task.id).options(selectinload(Task.requirements)) + ) -# result = await db_session.execute(stmt) -# db_task = result.unique().scalar_one() + result = await db_session.execute(stmt) + db_task = result.unique().scalar_one() -# assert len(db_task.requirements) == 1 -# assert db_task.requirements[0].name == "Test Option" + assert len(db_task.requirements) == 1 + assert db_task.requirements[0].name == option.name async def test_task_without_data(db_session): @@ -223,7 +211,7 @@ async def test_task_without_data(db_session): await db_session.rollback() -async def test_task_invalid_time(db_session, create): +async def test_task_invalid_time(create): task = await create( TaskFactory, start_time=datetime.now(), @@ -233,31 +221,19 @@ async def test_task_invalid_time(db_session, create): assert task.end_time < task.start_time -# async def test_task_category_hierarchy(db_session): -# parent = TaskRequirementCategory( -# name="Programming", display_name="Programming Languages" -# ) -# db_session.add(parent) -# await db_session.flush() - -# child = TaskRequirementCategory( -# name="Python", -# display_name="Python Language", -# main_id="Programming", -# parent_category=parent, -# ) -# db_session.add(child) -# await db_session.commit() - -# stmt = ( -# select(TaskRequirementCategory) -# .where(TaskRequirementCategory.name == "Programming") -# .options(selectinload(TaskRequirementCategory.sub_categories)) -# ) - -# result = await db_session.execute(stmt) -# db_parent = result.unique().scalar_one() - -# assert len(db_parent.sub_categories) == 1 -# assert db_parent.sub_categories[0].name == "Python" -# assert db_parent.sub_categories[0].parent_category.name == "Programming" +async def test_task_category_hierarchy(db_session, create): + parent = await create(TaskRequirementCategoryFactory) + child = await create(TaskRequirementCategoryFactory, main_id=parent.name) + + stmt = ( + select(TaskRequirementCategory) + .where(TaskRequirementCategory.name == parent.name) + .options(selectinload(TaskRequirementCategory.sub_categories)) + ) + + result = await db_session.execute(stmt) + db_parent = result.unique().scalar_one() + + assert len(db_parent.sub_categories) == 1 + assert db_parent.sub_categories[0].name == child.name + assert db_parent.sub_categories[0].parent_category.name == parent.name From f77671454af15966879ddd87f39232495686989e Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sun, 8 Mar 2026 22:24:27 +0200 Subject: [PATCH 033/369] feat: add notification and role request models --- backend/alembic/versions/0b99af1c589a_.py | 39 +++++++++++++++++++++ backend/alembic/versions/79c35894ec8e_.py | 39 +++++++++++++++++++++ backend/alembic/versions/c93036e26c52_.py | 42 +++++++++++++++++++++++ backend/app/models/__init__.py | 2 ++ backend/app/models/notification.py | 20 +++++++++++ backend/app/models/role.py | 3 ++ backend/app/models/role_request.py | 24 +++++++++++++ backend/app/models/user.py | 6 ++++ backend/app/schemas/notification.py | 15 ++++++++ backend/app/schemas/user.py | 4 +++ 10 files changed, 194 insertions(+) create mode 100644 backend/alembic/versions/0b99af1c589a_.py create mode 100644 backend/alembic/versions/79c35894ec8e_.py create mode 100644 backend/alembic/versions/c93036e26c52_.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/role_request.py create mode 100644 backend/app/schemas/notification.py diff --git a/backend/alembic/versions/0b99af1c589a_.py b/backend/alembic/versions/0b99af1c589a_.py new file mode 100644 index 0000000..d07526c --- /dev/null +++ b/backend/alembic/versions/0b99af1c589a_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: 0b99af1c589a +Revises: c93036e26c52 +Create Date: 2026-03-08 22:03:18.929262 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0b99af1c589a' +down_revision: Union[str, Sequence[str], None] = 'c93036e26c52' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('notifications', + sa.Column('body', sa.String(length=4096), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('body') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('notifications') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/79c35894ec8e_.py b/backend/alembic/versions/79c35894ec8e_.py new file mode 100644 index 0000000..8b03e3f --- /dev/null +++ b/backend/alembic/versions/79c35894ec8e_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: 79c35894ec8e +Revises: 0b99af1c589a +Create Date: 2026-03-08 22:23:54.110885 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '79c35894ec8e' +down_revision: Union[str, Sequence[str], None] = '0b99af1c589a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('role_requests', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('role_requests') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/c93036e26c52_.py b/backend/alembic/versions/c93036e26c52_.py new file mode 100644 index 0000000..f317272 --- /dev/null +++ b/backend/alembic/versions/c93036e26c52_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: c93036e26c52 +Revises: 9c3d5ce955b1 +Create Date: 2026-03-08 22:01:28.106624 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c93036e26c52' +down_revision: Union[str, Sequence[str], None] = '9c3d5ce955b1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.create_foreign_key('fk_task_tournament', 'tournaments', ['tournament_id'], ['id'], use_alter=True) + + with op.batch_alter_table('team_members', schema=None) as batch_op: + batch_op.create_foreign_key('fk_teammember_team', 'teams', ['team_id'], ['id'], use_alter=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('team_members', schema=None) as batch_op: + batch_op.drop_constraint('fk_teammember_team', type_='foreignkey') + + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.drop_constraint('fk_task_tournament', type_='foreignkey') + + # ### end Alembic commands ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1becc32..36a39ab 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,3 +4,5 @@ from .user import User from .tournament import Tournament, TournamentStatusOption from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption +from .notification import Notification +from .role_request import RoleRequest \ No newline at end of file diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..701e35b --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey + +from .base import Base +from .mixin import PKMixin +from .user import User + + +class Notification(Base, PKMixin): + __tablename__ = "notifications" + + body: Mapped[str] = mapped_column(String(4096), unique=True) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + + user: Mapped["User"] = relationship( + back_populates="notifications" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 27b977d..4fb9b95 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -13,6 +13,9 @@ class Role(Base, PKMixin): users: Mapped[list["User"]] = relationship( secondary=user_roles, back_populates="roles" ) + requests: Mapped[list["RoleRequest"]] = relationship( + back_populates="role" + ) def __repr__(self): return f"" diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py new file mode 100644 index 0000000..f04663f --- /dev/null +++ b/backend/app/models/role_request.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey + +from .base import Base +from .mixin import PKMixin +from .user import User +from .role import Role + + +class RoleRequest(Base, PKMixin): + __tablename__ = "role_requests" + + role_id: Mapped[int] = mapped_column(ForeignKey('roles.id')) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + + role: Mapped["Role"] = relationship( + back_populates="requests" + ) + user: Mapped["User"] = relationship( + back_populates="role_requests" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1f7d7f1..0fe187b 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -24,6 +24,12 @@ class User(Base, PKMixin): roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users" ) + notifications: Mapped[list["Notification"]] = relationship( + back_populates="user" + ) + requests: Mapped[list["RoleRequest"]] = relationship( + back_populates="user" + ) created_tournaments: Mapped[list["Tournament"]] = relationship( back_populates="creator" ) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..5e725ee --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field, field_validator + + +class NotificationModel(BaseModel): + body: str = Field(..., description="Notification body") + user: str + + @field_validator("body") + @classmethod + def check_body(cls, value: str): + if not value.strip(): + raise ValueError("The body cannot be empty") + if len(value.strip()) > 4096: + raise ValueError('The body cannot be longer than 4096 characters') + return value diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3decf61..a179c76 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -18,3 +18,7 @@ def check_name(cls, value: str): @classmethod def check_email(cls, value: str): return value.lower().strip() + +# Return notifications of current user only +class CurrentUser(UserModel): + notifications: list[str] \ No newline at end of file From 8e3d0e5434b2a18d3500bd56bdf05544bacfa420 Mon Sep 17 00:00:00 2001 From: DaniilV Date: Sun, 8 Mar 2026 23:38:49 +0200 Subject: [PATCH 034/369] feat(routes): implement POST/PATCH/DELETE tournaments and register/leave/participants --- backend/app/routes/tournaments.py | 109 +++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 16913f2..49b9ee0 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -18,4 +18,111 @@ async def tournament(tournament_id: int, session: SessionDep): tournament = await session.execute(statement) if not tournament.first(): raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') - return tournament.first() \ No newline at end of file + return tournament.first() + +@router.post('/', response_model=TournamentModels, status_code=status.HTTP_201_CREATED) +async def create_tournament( + creator_id: int, + status_id: int, + tournament: TournamentModels, + session: SessionDep, +): + new_tournament = Tournament( + title=tournament.title, + description=tournament.description, + start_date=tournament.start_date, + reg_start=tournament.reg_start, + reg_end=tournament.reg_end, + max_team=tournament.max_team, + creator_id=creator_id, + status_id=status_id, + ) + session.add(new_tournament) + await session.commit() + statement = select(Tournament).where(Tournament.id == new_tournament.id) + created = await session.execute(statement) + return created.first() + + +@router.patch('/{tournament_id}/', response_model=TournamentModels) +async def update_tournament( + tournament_id: int, + tournament_data: TournamentModels, + session: SessionDep, +): + statement = select(Tournament).where(Tournament.id == tournament_id) + result = await session.execute(statement) + tournament = result.scalar_one_or_none() + if not tournament: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') + tournament.title = tournament_data.title + tournament.description = tournament_data.description + tournament.start_date = tournament_data.start_date + tournament.reg_start = tournament_data.reg_start + tournament.reg_end = tournament_data.reg_end + tournament.max_team = tournament_data.max_team + await session.commit() + return tournament + + +@router.delete('/{tournament_id}/', status_code=status.HTTP_204_NO_CONTENT) +async def delete_tournament(tournament_id: int, session: SessionDep): + statement = select(Tournament).where(Tournament.id == tournament_id) + result = await session.execute(statement) + tournament = result.scalar_one_or_none() + if not tournament: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') + await session.delete(tournament) + await session.commit() + + +@router.post('/{tournament_id}/register', response_model=TeamModel, status_code=status.HTTP_201_CREATED) +async def register_team( + tournament_id: int, + team: TeamModel, + session: SessionDep, +): + new_team = Team( + name=team.name, + team_email=team.team_email, + contact_info=str(team.contact_info), + tournament_id=tournament_id, + captain_id=team.captain_id, + ) + session.add(new_team) + await session.commit() + statement = select(Team).where(Team.id == new_team.id) + created = await session.execute(statement) + return created.first() + + +@router.post('/{tournament_id}/leave', status_code=status.HTTP_204_NO_CONTENT) +async def leave_tournament( + tournament_id: int, + team_id: int, + session: SessionDep, +): + statement = select(Team).where( + Team.id == team_id, + Team.tournament_id == tournament_id, + ) + result = await session.execute(statement) + team = result.scalar_one_or_none() + if not team: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail='Team not found in this tournament', + ) + await session.delete(team) + await session.commit() + + +@router.get('/{tournament_id}/participants', response_model=list[TeamModel]) +async def tournament_participants( + tournament_id: int, + session: SessionDep, +): + statement = select(Team).where(Team.tournament_id == tournament_id) + teams = await session.execute(statement) + return teams.all() + From fa70cc5a46ad581742b612616875e67ba2c9bd62 Mon Sep 17 00:00:00 2001 From: izachoc Date: Mon, 9 Mar 2026 03:13:22 +0200 Subject: [PATCH 035/369] feat: Added stable components --- frontend/index.html | 27 +- frontend/package-lock.json | 19 + frontend/package.json | 1 + frontend/public/hedgehog.json | 1 + frontend/src/App.tsx | 44 ++- frontend/src/components/Footer.tsx | 110 ++++++ frontend/src/components/Header.tsx | 52 +++ frontend/src/components/Hero.tsx | 112 ++++++ frontend/src/components/TournamentCard.tsx | 92 +++++ frontend/src/index.css | 131 ++++--- frontend/src/main.tsx | 12 +- frontend/src/pages/Auth/Auth.tsx | 8 +- frontend/src/pages/Home/Home.tsx | 69 +++- .../Home/components/TournamentSlider.tsx | 263 +++++++++++++ frontend/src/pages/Page404/Page404.tsx | 72 ++-- frontend/src/pages/Profile/Profile.tsx | 8 +- .../pages/TournamentPage/TournamentPage.tsx | 349 +++++++++++++++++- .../pages/TournamentsPage/TournamentsPage.tsx | 8 +- 18 files changed, 1221 insertions(+), 157 deletions(-) create mode 100644 frontend/public/hedgehog.json create mode 100644 frontend/src/components/Footer.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/Hero.tsx create mode 100644 frontend/src/components/TournamentCard.tsx create mode 100644 frontend/src/pages/Home/components/TournamentSlider.tsx diff --git a/frontend/index.html b/frontend/index.html index f4ac329..7641770 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,12 +1,19 @@ - - - - - Tournament - - -
- - + + + + + UGalaxy | Star for Life + + + + + + +
+ + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7cb2f29..1e1cdc9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@lottiefiles/react-lottie-player": "^3.6.0", "@tailwindcss/vite": "^4.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -981,6 +982,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lottiefiles/react-lottie-player": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.6.0.tgz", + "integrity": "sha512-WK5TriLJT93VF3w4IjSVyveiedraZCnDhKzCPhpbeLgQeMi6zufxa3dXNc4HmAFRXq+LULPAy+Idv1rAfkReMA==", + "license": "MIT", + "dependencies": { + "lottie-web": "^5.12.2" + }, + "peerDependencies": { + "react": "16 - 19" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3116,6 +3129,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 776c2f8..83f9974 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@lottiefiles/react-lottie-player": "^3.6.0", "@tailwindcss/vite": "^4.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/public/hedgehog.json b/frontend/public/hedgehog.json new file mode 100644 index 0000000..ab70e3a --- /dev/null +++ b/frontend/public/hedgehog.json @@ -0,0 +1 @@ +{"tgs":1,"v":"5.5.2.2","fr":60,"ip":0,"op":180,"w":512,"h":512,"nm":"10_HELLO_OUT","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"babo","parent":17,"sr":1,"ks":{"r":{"a":0,"k":0.249},"p":{"a":0,"k":[9.417,84.483,0]},"s":{"a":0,"k":[133.988,128.79,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.706,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[-0.957,2.063],[0,0],[-15.17,-7.792],[0,0]],"o":[[0,0],[0,0],[0.957,-2.063],[0,0],[1.928,0.99],[0,0]],"v":[[0.119,-1.315],[-33.227,-12.709],[-26.419,17.408],[0.119,-1.315],[32.37,15.49],[32.37,-17.503]],"c":true}]},{"t":120,"s":[{"i":[[0,0],[0,0],[0.428,-7.414],[0,0],[-13.306,-2.465],[0,0]],"o":[[0,0],[0,0],[-0.131,2.271],[0,0],[2.131,0.395],[0,0]],"v":[[0.119,-1.315],[-28.08,-16.522],[-37.658,11.938],[0.119,-1.315],[22.391,7.035],[35.15,-18.93]],"c":true}],"h":1},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[{"i":[[0,0],[0,0],[0.428,-7.414],[0,0],[-13.306,-2.465],[0,0]],"o":[[0,0],[0,0],[-0.131,2.271],[0,0],[2.131,0.395],[0,0]],"v":[[0.119,-1.315],[-28.08,-16.522],[-37.658,11.938],[0.119,-1.315],[22.391,7.035],[35.15,-18.93]],"c":true}]},{"t":176,"s":[{"i":[[0,0],[0,0],[-0.957,2.063],[0,0],[-15.17,-7.792],[0,0]],"o":[[0,0],[0,0],[0.957,-2.063],[0,0],[1.928,0.99],[0,0]],"v":[[0.119,-1.315],[-33.227,-12.709],[-26.419,17.408],[0.119,-1.315],[32.37,15.49],[32.37,-17.503]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215686275,0.2,0.2,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.240594362745,0.199831899007,0.199831899007,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"hat_t 4","parent":3,"sr":1,"ks":{},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[{"i":[[-0.966,-11.915],[77.038,-0.149],[0.966,11.915],[-77.038,0.149]],"o":[[0.966,11.915],[-77.038,0.149],[-0.966,-11.915],[77.038,-0.149]],"v":[[139.49,-0.27],[1.748,21.574],[-139.49,0.27],[-1.748,-21.574]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[-0.587,-0.574],[46.818,-0.007],[0.587,0.574],[-46.818,0.007]],"o":[[0.587,0.574],[-46.818,0.007],[-0.587,-0.574],[46.818,-0.007]],"v":[[84.771,-0.013],[1.063,1.039],[-84.771,0.013],[-1.063,-1.039]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":131,"s":[{"i":[[-0.631,4.221],[50.323,0.053],[0.631,-4.221],[-50.323,-0.053]],"o":[[0.631,-4.221],[-50.323,-0.053],[-0.631,4.221],[50.323,0.053]],"v":[[91.117,0.096],[1.142,-7.642],[-91.117,-0.096],[-1.142,7.642]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":146,"s":[{"i":[[-0.631,4.221],[50.323,0.053],[0.631,-4.221],[-50.323,-0.053]],"o":[[0.631,-4.221],[-50.323,-0.053],[-0.631,4.221],[50.323,0.053]],"v":[[91.117,0.096],[1.142,-7.642],[-91.117,-0.096],[-1.142,7.642]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":156,"s":[{"i":[[-0.587,-0.574],[46.818,-0.007],[0.587,0.574],[-46.818,0.007]],"o":[[0.587,0.574],[-46.818,0.007],[-0.587,-0.574],[46.818,-0.007]],"v":[[84.771,-0.013],[1.063,1.039],[-84.771,0.013],[-1.063,-1.039]],"c":true}]},{"t":166,"s":[{"i":[[-0.966,-11.915],[77.038,-0.149],[0.966,11.915],[-77.038,0.149]],"o":[[0.966,11.915],[-77.038,0.149],[-0.966,-11.915],[77.038,-0.149]],"v":[[139.49,-0.27],[1.748,21.574],[-139.49,0.27],[-1.748,-21.574]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.671292892157,0.4375980452,0.178795489143,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.670588235294,0.439215686275,0.180392156863,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":107,"op":154,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"hat_t 2","parent":8,"sr":1,"ks":{},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[{"i":[[-0.966,-11.915],[77.038,-0.149],[0.966,11.915],[-77.038,0.149]],"o":[[0.966,11.915],[-77.038,0.149],[-0.966,-11.915],[77.038,-0.149]],"v":[[139.49,-0.27],[1.748,21.574],[-139.49,0.27],[-1.748,-21.574]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[-0.966,0.05],[77.038,0.001],[0.966,-0.05],[-77.038,-0.001]],"o":[[0.966,-0.05],[-77.038,-0.001],[-0.966,0.05],[77.038,0.001]],"v":[[139.49,0.001],[1.748,-0.091],[-139.49,-0.001],[-1.748,0.091]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":131,"s":[{"i":[[-0.966,12.999],[77.038,0.163],[0.966,-12.999],[-77.038,-0.163]],"o":[[0.966,-12.999],[-77.038,-0.163],[-0.966,12.999],[77.038,0.163]],"v":[[139.49,0.295],[1.748,-23.537],[-139.49,-0.295],[-1.748,23.537]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":146,"s":[{"i":[[-0.966,12.999],[77.038,0.163],[0.966,-12.999],[-77.038,-0.163]],"o":[[0.966,-12.999],[-77.038,-0.163],[-0.966,12.999],[77.038,0.163]],"v":[[139.49,0.295],[1.748,-23.537],[-139.49,-0.295],[-1.748,23.537]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":156,"s":[{"i":[[-0.966,0.05],[77.038,0.001],[0.966,-0.05],[-77.038,-0.001]],"o":[[0.966,-0.05],[-77.038,-0.001],[-0.966,0.05],[77.038,0.001]],"v":[[139.49,0.001],[1.748,-0.091],[-139.49,-0.001],[-1.748,0.091]],"c":true}]},{"t":166,"s":[{"i":[[-0.966,-11.915],[77.038,-0.149],[0.966,11.915],[-77.038,0.149]],"o":[[0.966,11.915],[-77.038,0.149],[-0.966,-11.915],[77.038,-0.149]],"v":[[139.49,-0.27],[1.748,21.574],[-139.49,0.27],[-1.748,-21.574]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.866666666667,0.650980392157,0.16862745098,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.831372559071,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":102,"op":157,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Layer 2 copy","parent":5,"sr":1,"ks":{"r":{"a":0,"k":2.916},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":74,"s":[-45.77,2.322,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":101,"s":[-41.111,-9.121,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":130,"s":[-41.111,-9.121,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":145,"s":[-41.111,-9.121,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":155,"s":[-41.111,-9.121,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":165,"s":[-45.77,2.322,0],"to":[0,0,0],"ti":[0,0,0]},{"t":176,"s":[-45.77,2.322,0]}]},"a":{"a":0,"k":[1525.054,-1499.111,0]},"s":{"a":0,"k":[70.783,68.009,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":176,"s":[{"i":[[0,-9.711],[9.711,0],[0,9.711],[-9.711,0]],"o":[[0,9.711],[-9.711,0],[0,-9.711],[9.711,0]],"v":[[17.584,0],[0,17.584],[-17.584,0],[0,-17.584]],"c":true}]},{"i":{"x":0.37,"y":1},"o":{"x":0.333,"y":0},"t":202,"s":[{"i":[[0,-9.711],[8.657,0],[0,9.711],[-8.657,0]],"o":[[0,9.711],[-8.657,0],[0,-9.711],[8.657,0]],"v":[[4.621,0.331],[-11.054,17.915],[-26.729,0.331],[-11.054,-17.252]],"c":true}]},{"t":225,"s":[{"i":[[0,-9.711],[9.711,0],[0,9.711],[-9.711,0]],"o":[[0,9.711],[-9.711,0],[0,-9.711],[9.711,0]],"v":[[17.584,0],[0,17.584],[-17.584,0],[0,-17.584]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.831372559071,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[1525.054,-1499.111]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":176,"s":[{"i":[[2.945,5.101],[11.372,-2.974],[5.891,0],[3.11,-11.335],[2.945,-5.101],[-8.261,-8.361],[-2.945,-5.101],[-11.372,2.974],[-5.891,0],[-3.11,11.335],[-2.945,5.101],[8.261,8.361]],"o":[[-2.945,-5.101],[-3.11,-11.335],[-5.891,0],[-11.372,-2.974],[-2.945,5.101],[-8.261,8.361],[2.945,5.101],[3.11,11.335],[5.891,0],[11.372,2.974],[2.945,-5.101],[8.261,-8.361]],"v":[[37.553,-21.681],[14.199,-24.593],[0,-43.362],[-14.199,-24.593],[-37.553,-21.681],[-28.398,0],[-37.553,21.681],[-14.199,24.593],[0,43.362],[14.199,24.593],[37.553,21.681],[28.398,0]],"c":true}]},{"i":{"x":0.37,"y":1},"o":{"x":0.333,"y":0},"t":202,"s":[{"i":[[2.626,5.101],[10.137,-2.974],[5.251,0],[2.773,-11.335],[2.626,-5.101],[-7.365,-8.361],[-2.626,-5.101],[-10.137,2.974],[-5.251,0],[-2.773,11.335],[-2.625,5.101],[7.365,8.361]],"o":[[-2.626,-5.101],[-2.773,-11.335],[-5.251,0],[-10.137,-2.974],[-2.626,5.101],[-7.365,8.361],[2.626,5.101],[2.773,11.335],[5.251,0],[10.137,2.974],[2.625,-5.101],[7.365,-8.361]],"v":[[22.422,-21.35],[1.604,-24.262],[-11.054,-43.031],[-23.712,-24.262],[-44.53,-21.35],[-36.369,0.332],[-44.53,22.012],[-23.712,24.925],[-11.054,43.693],[1.604,24.925],[22.422,22.012],[14.261,0.332]],"c":true}]},{"t":225,"s":[{"i":[[2.945,5.101],[11.372,-2.974],[5.891,0],[3.11,-11.335],[2.945,-5.101],[-8.261,-8.361],[-2.945,-5.101],[-11.372,2.974],[-5.891,0],[-3.11,11.335],[-2.945,5.101],[8.261,8.361]],"o":[[-2.945,-5.101],[-3.11,-11.335],[-5.891,0],[-11.372,-2.974],[-2.945,5.101],[-8.261,8.361],[2.945,5.101],[3.11,11.335],[5.891,0],[11.372,2.974],[2.945,-5.101],[8.261,-8.361]],"v":[[37.553,-21.681],[14.199,-24.593],[0,-43.362],[-14.199,-24.593],[-37.552,-21.681],[-28.398,0],[-37.552,21.681],[-14.199,24.593],[0,43.362],[14.199,24.593],[37.553,21.681],[28.398,0]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.509803950787,0.803921580315,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[1525.054,-1499.111]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false}],"ip":-1,"op":299,"st":-1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"hat_black","parent":8,"sr":1,"ks":{"p":{"a":0,"k":[-0.661,-23.478,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[{"i":[[0,0],[12.714,6.89],[1.026,-8.218],[1.259,11.181]],"o":[[-15.318,6.477],[-0.258,4.524],[-1.026,8.218],[-1.259,-11.181]],"v":[[83.133,-8.581],[-82.398,-8.577],[-86.575,21.345],[86.575,22.828]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":101,"s":[{"i":[[0,0],[19.034,-0.767],[1.026,-8.218],[-32.446,0.233]],"o":[[-25.775,-1.653],[-0.258,4.524],[19.897,0.022],[-1.259,-11.181]],"v":[[83.174,-11.632],[-82.358,-11.628],[-85.237,15.641],[84.989,15.519]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":131,"s":[{"i":[[0,0],[15.754,-18.11],[1.026,-8.218],[1.259,11.181]],"o":[[-19.403,-16.302],[-0.258,4.524],[-1.026,8.218],[-1.259,-11.181]],"v":[[83.133,-8.581],[-82.398,-8.577],[-86.575,21.345],[86.575,22.828]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":146,"s":[{"i":[[0,0],[15.754,-18.11],[1.026,-8.218],[1.259,11.181]],"o":[[-19.403,-16.302],[-0.258,4.524],[-1.026,8.218],[-1.259,-11.181]],"v":[[83.133,-8.581],[-82.398,-8.577],[-86.575,21.345],[86.575,22.828]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":156,"s":[{"i":[[0,0],[19.034,-0.767],[1.026,-8.218],[-32.446,0.233]],"o":[[-25.775,-1.653],[-0.258,4.524],[19.897,0.022],[-1.259,-11.181]],"v":[[83.174,-11.632],[-82.358,-11.628],[-85.237,15.641],[84.989,15.519]],"c":true}]},{"t":166,"s":[{"i":[[0,0],[12.714,6.89],[1.026,-8.218],[1.259,11.181]],"o":[[-15.318,6.477],[-0.258,4.524],[-1.026,8.218],[-1.259,-11.181]],"v":[[83.133,-8.581],[-82.398,-8.577],[-86.575,21.345],[86.575,22.828]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.149019613862,0.156862750649,0.152941182256,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":269,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"HAT_b 2","parent":7,"sr":1,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":102,"s":[0],"h":1},{"t":156,"s":[100],"h":1}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[{"i":[[0,0],[-3.874,13.602]],"o":[[3.895,11.825],[-0.546,-2.792]],"v":[[-73.169,-27.891],[75.994,-27.905]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[-3.874,1.084]],"o":[[3.895,0.942],[-0.546,-0.222]],"v":[[-73.169,-30.473],[75.994,-30.474]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":156,"s":[{"i":[[0,0],[-8.907,-4.587]],"o":[[8.546,-4.94],[-0.546,-0.222]],"v":[[-74.391,-26.962],[76.179,-28.202]],"c":false}]},{"t":166,"s":[{"i":[[0,0],[-3.874,13.602]],"o":[[3.895,11.825],[-0.546,-2.792]],"v":[[-73.169,-27.891],[75.994,-27.905]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"tm","s":{"a":0,"k":20},"e":{"a":0,"k":100},"o":{"a":0,"k":0},"m":1,"nm":"Trim Paths 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.866666666667,0.650980392157,0.16862745098,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":7},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":289,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"HAT_b","parent":8,"sr":1,"ks":{"p":{"a":0,"k":[0,-49.472,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[{"i":[[0,0],[2.168,-11.913],[1.195,-17.217],[1.162,12.183]],"o":[[-2.362,-13.992],[0.546,2.792],[-1.195,17.217],[-1.162,-12.183]],"v":[[76.183,-27.535],[-75.431,-27.53],[-82.067,39.59],[82.067,41.073]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[10.725,-1.741],[1.195,-17.217],[1.162,12.183]],"o":[[-17.284,-5.966],[0.546,2.792],[-1.195,17.217],[-1.162,-12.183]],"v":[[76.183,-27.535],[-75.431,-27.53],[-82.067,39.59],[82.067,41.073]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":131,"s":[{"i":[[0,0],[3.425,-21.933],[1.195,-17.217],[1.162,12.183]],"o":[[-2.631,-24.209],[0.546,2.792],[-1.195,17.217],[-1.162,-12.183]],"v":[[76.183,-27.535],[-75.431,-27.53],[-82.067,39.59],[82.067,41.073]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":146,"s":[{"i":[[0,0],[3.425,-21.933],[1.195,-17.217],[1.162,12.183]],"o":[[-2.631,-24.209],[0.546,2.792],[-1.195,17.217],[-1.162,-12.183]],"v":[[76.183,-27.535],[-75.431,-27.53],[-82.067,39.59],[82.067,41.073]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":156,"s":[{"i":[[0,0],[10.725,-1.741],[1.195,-17.217],[1.162,12.183]],"o":[[-17.284,-5.966],[0.546,2.792],[-1.195,17.217],[-1.162,-12.183]],"v":[[76.183,-27.535],[-75.431,-27.53],[-82.067,39.59],[82.067,41.073]],"c":true}]},{"t":166,"s":[{"i":[[0,0],[2.168,-11.913],[1.195,-17.217],[1.162,12.183]],"o":[[-2.362,-13.992],[0.546,2.792],[-1.195,17.217],[-1.162,-12.183]],"v":[[76.183,-27.535],[-75.431,-27.53],[-82.067,39.59],[82.067,41.073]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.866666666667,0.650980392157,0.16862745098,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.831372559071,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":289,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"hat_t","parent":23,"sr":1,"ks":{"o":{"a":1,"k":[{"t":59,"s":[100],"h":1},{"t":102,"s":[0],"h":1},{"t":156,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":74,"s":[-3.961]},{"i":{"x":[0.141],"y":[2.413]},"o":{"x":[0.333],"y":[0]},"t":130,"s":[37.971]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":145,"s":[37.971]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":165,"s":[-3.961]},{"t":202,"s":[-3.961]}]},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":74,"s":[-22.126,-164.845,0],"to":[0,0,0],"ti":[-52.331,-9.451,0]},{"t":130,"s":[46.96,-255.218,0],"h":1},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":148,"s":[46.985,-255.23,0],"to":[-57.702,1.424,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":168,"s":[-13.759,-140.335,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":177,"s":[-22.126,-164.845,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":202,"s":[-22.126,-164.845,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":225,"s":[-11.405,-164.716,0],"to":[0,0,0],"ti":[0,0,0]},{"t":238,"s":[-22.126,-164.845,0]}]},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":152,"s":[142,142,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":165,"s":[145,138,100]},{"t":173,"s":[142,142,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":74,"s":[{"i":[[-0.966,-11.915],[77.038,-0.149],[0.966,11.915],[-77.038,0.149]],"o":[[0.966,11.915],[-77.038,0.149],[-0.966,-11.915],[77.038,-0.149]],"v":[[139.49,-0.27],[1.748,21.574],[-139.49,0.27],[-1.748,-21.574]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":101,"s":[{"i":[[-0.966,0.05],[77.038,0.001],[0.966,-0.05],[-77.038,-0.001]],"o":[[0.966,-0.05],[-77.038,-0.001],[-0.966,0.05],[77.038,0.001]],"v":[[139.49,0.001],[1.748,-0.091],[-139.49,-0.001],[-1.748,0.091]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":130,"s":[{"i":[[-0.966,12.999],[77.038,0.163],[0.966,-12.999],[-77.038,-0.163]],"o":[[0.966,-12.999],[-77.038,-0.163],[-0.966,12.999],[77.038,0.163]],"v":[[139.49,0.295],[1.748,-23.537],[-139.49,-0.295],[-1.748,23.537]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":145,"s":[{"i":[[-0.966,12.999],[77.038,0.163],[0.966,-12.999],[-77.038,-0.163]],"o":[[0.966,-12.999],[-77.038,-0.163],[-0.966,12.999],[77.038,0.163]],"v":[[139.49,0.295],[1.748,-23.537],[-139.49,-0.295],[-1.748,23.537]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":155,"s":[{"i":[[-0.966,0.05],[77.038,0.001],[0.966,-0.05],[-77.038,-0.001]],"o":[[0.966,-0.05],[-77.038,-0.001],[-0.966,0.05],[77.038,0.001]],"v":[[139.49,0.001],[1.748,-0.091],[-139.49,-0.001],[-1.748,0.091]],"c":true}]},{"t":165,"s":[{"i":[[-0.966,-11.915],[77.038,-0.149],[0.966,11.915],[-77.038,0.149]],"o":[[0.966,11.915],[-77.038,0.149],[-0.966,-11.915],[77.038,-0.149]],"v":[[139.49,-0.27],[1.748,21.574],[-139.49,0.27],[-1.748,-21.574]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.868612132353,0.652802231733,0.170422542796,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.831372559071,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":-1,"op":287,"st":-1,"bm":0},{"ddd":0,"ind":9,"ty":3,"nm":"ALL_SKALE","sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":0,"k":[256.136,487.44,0]},"a":{"a":0,"k":[60,60,0]},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":60,"s":[81,81,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":79,"s":[79.2,82.8,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":89,"s":[82.8,79.2,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":99,"s":[81,81,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":165,"s":[81,81,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":171,"s":[82.8,79.2,100]},{"t":179,"s":[81,81,100]}]}},"ao":0,"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"hand_l","parent":23,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[146.844]},{"i":{"x":[0.58],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":81,"s":[-4.787]},{"i":{"x":[0.58],"y":[1]},"o":{"x":[0.413],"y":[0]},"t":106,"s":[28.276]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.413],"y":[0]},"t":142,"s":[28.276]},{"i":{"x":[0.835],"y":[1]},"o":{"x":[0.168],"y":[0]},"t":160,"s":[-4.787]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":176,"s":[-4.787]},{"t":187,"s":[146.844]}]},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[-135.633,69.147,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":81,"s":[-138.132,56.404,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.58,"y":0.58},"o":{"x":0.413,"y":0.413},"t":106,"s":[-130.262,81.021,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.413,"y":0.413},"t":142,"s":[-130.262,81.021,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.835,"y":1},"o":{"x":0.168,"y":0},"t":160,"s":[-130.262,81.021,0],"to":[0,0,0],"ti":[0,0,0]},{"t":176,"s":[-135.633,69.147,0]}]},"a":{"a":0,"k":[-6.043,-54.406,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":63,"s":[{"i":[[-20.246,-0.164],[-25.837,-42.482]],"o":[[84.347,0.682],[14.221,23.382]],"v":[[-6.043,-54.406],[32.02,25.514]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":84,"s":[{"i":[[5.986,-19.341],[4.658,-31.503]],"o":[[-5.986,19.341],[-4.003,27.072]],"v":[[-6.043,-54.406],[-23.743,48.813]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":109,"s":[{"i":[[1.711,-20.174],[16.653,-10.459]],"o":[[-2.705,31.894],[-23.175,14.555]],"v":[[-6.043,-54.406],[-68.702,41.734]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":145,"s":[{"i":[[1.711,-20.174],[16.653,-10.459]],"o":[[-2.705,31.894],[-23.175,14.555]],"v":[[-6.043,-54.406],[-68.702,41.734]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":163,"s":[{"i":[[1.003,-20.222],[-13.659,-24.822]],"o":[[-1.554,31.346],[13.193,23.977]],"v":[[-6.043,-54.406],[22.877,47.401]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":178,"s":[{"i":[[5.986,-19.341],[4.658,-31.503]],"o":[[-5.986,19.341],[-4.003,27.072]],"v":[[-6.043,-54.406],[-23.743,48.813]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":184,"s":[{"i":[[-20.241,-0.486],[40.03,-21.378]],"o":[[53.9,1.293],[-24.14,12.892]],"v":[[-6.043,-54.406],[9.074,11.655]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":190,"s":[{"i":[[-20.241,-0.486],[40.03,-21.378]],"o":[[53.9,1.293],[-24.14,12.892]],"v":[[-6.043,-54.406],[9.074,11.655]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":196,"s":[{"i":[[-20.246,-0.164],[-25.837,-42.482]],"o":[[84.347,0.682],[14.221,23.382]],"v":[[-6.043,-54.406],[32.02,25.514]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":201,"s":[{"i":[[-20.241,-0.486],[40.03,-21.378]],"o":[[53.9,1.293],[-24.14,12.892]],"v":[[-6.043,-54.406],[9.074,11.655]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":206,"s":[{"i":[[-20.246,-0.164],[-25.837,-42.482]],"o":[[84.347,0.682],[14.221,23.382]],"v":[[-6.043,-54.406],[32.02,25.514]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":212,"s":[{"i":[[-20.241,-0.486],[40.03,-21.378]],"o":[[53.9,1.293],[-24.14,12.892]],"v":[[-6.043,-54.406],[9.074,11.655]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":217,"s":[{"i":[[-20.246,-0.164],[-25.837,-42.482]],"o":[[84.347,0.682],[14.221,23.382]],"v":[[-6.043,-54.406],[32.02,25.514]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":223,"s":[{"i":[[-20.241,-0.486],[40.03,-21.378]],"o":[[53.9,1.293],[-24.14,12.892]],"v":[[-6.043,-54.406],[9.074,11.655]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":228,"s":[{"i":[[-20.246,-0.164],[-25.837,-42.482]],"o":[[84.347,0.682],[14.221,23.382]],"v":[[-6.043,-54.406],[32.02,25.514]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":234,"s":[{"i":[[-20.241,-0.486],[40.03,-21.378]],"o":[[53.9,1.293],[-24.14,12.892]],"v":[[-6.043,-54.406],[9.074,11.655]],"c":false}]},{"t":240,"s":[{"i":[[-20.246,-0.164],[-25.837,-42.482]],"o":[[84.347,0.682],[14.221,23.382]],"v":[[-6.043,-54.406],[32.02,25.514]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.784313738346,0.701960802078,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"hand_l_shd","parent":23,"sr":1,"ks":{"o":{"a":0,"k":40},"p":{"a":0,"k":[-108.393,90.261,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[{"i":[[-5.11,-19.008],[-17.147,-11.731]],"o":[[5.277,19.627],[0,0]],"v":[[-26.987,-20.859],[19.997,30.214]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":81,"s":[{"i":[[-5.11,-19.008],[-17.147,-11.731]],"o":[[5.277,19.627],[0,0]],"v":[[-26.987,-20.859],[19.997,30.214]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.413,"y":0},"t":106,"s":[{"i":[[-5.878,-18.785],[-11.99,-8.107]],"o":[[5.62,17.961],[0,0]],"v":[[-19.506,-6.23],[16.177,34.709]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.413,"y":0},"t":142,"s":[{"i":[[-5.878,-18.785],[-11.99,-8.107]],"o":[[5.62,17.961],[0,0]],"v":[[-19.506,-6.23],[16.177,34.709]],"c":false}]},{"i":{"x":0.835,"y":1},"o":{"x":0.168,"y":0},"t":160,"s":[{"i":[[-5.878,-18.785],[-11.99,-8.107]],"o":[[5.62,17.961],[0,0]],"v":[[-19.506,-6.23],[16.177,34.709]],"c":false}]},{"t":176,"s":[{"i":[[-5.11,-19.008],[-17.147,-11.731]],"o":[[5.277,19.627],[0,0]],"v":[[-26.987,-20.859],[19.997,30.214]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.898039275525,0.619607843137,0.509803921569,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"hair_bl 2","parent":13,"sr":1,"ks":{"o":{"a":0,"k":44},"r":{"a":0,"k":5.461},"p":{"a":0,"k":[-192.592,-36.468,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[292.49,13.968],[277.475,-12.565]],"c":false}]},{"i":{"x":0.501,"y":1},"o":{"x":0.63,"y":0},"t":120,"s":[{"i":[[0.195,9.466],[-8.241,13.571]],"o":[[-0.379,-18.452],[8.523,-14.036]],"v":[[299.26,11.476],[310.838,-42.561]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[{"i":[[0.195,9.466],[-8.241,13.571]],"o":[[-0.379,-18.452],[8.523,-14.036]],"v":[[299.26,11.476],[310.838,-42.561]],"c":false}]},{"t":176,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[292.49,13.968],[277.475,-12.565]],"c":false}]}]},"nm":"Path 1","hd":false},{"ind":1,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[215.774,33.587],[203.128,3.344]],"c":false}]},{"i":{"x":0.501,"y":1},"o":{"x":0.63,"y":0},"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[215.774,33.587],[203.128,3.344]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[215.774,33.587],[203.128,3.344]],"c":false}]},{"t":176,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[215.774,33.587],[203.128,3.344]],"c":false}]}]},"nm":"Path 2","hd":false},{"ty":"st","c":{"a":0,"k":[0.858823529412,0.501960784314,0.341176470588,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":13},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.37],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.501],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":88,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":161,"s":[0]},{"t":176,"s":[0]}]},"e":{"a":1,"k":[{"i":{"x":[0.37],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":60,"s":[21]},{"i":{"x":[0.501],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":88,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":161,"s":[100]},{"t":176,"s":[21]}]},"o":{"a":0,"k":0},"m":1,"nm":"Trim Paths 1","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"hair2","parent":23,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.37],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.501],"y":[1]},"o":{"x":[0.465],"y":[0]},"t":120,"s":[-20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":156,"s":[-20]},{"t":176,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.63,"y":0},"t":60,"s":[-8.324,-115.666,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.501,"y":1},"o":{"x":0.709,"y":0},"t":120,"s":[25.013,-53.517,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[25.013,-53.517,0],"to":[0,0,0],"ti":[0,0,0]},{"t":176,"s":[-8.324,-115.666,0]}]},"s":{"a":0,"k":[-100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[0,0],[-82.293,-13.259],[0,0],[-34.507,-8.177],[0,0]],"o":[[19.495,25.054],[-6.704,-24.137],[22.346,11.668],[0.465,-12.358],[0,0]],"v":[[-98.441,-37.98],[38.159,35.769],[24.918,-11.127],[116.84,23.477],[98.441,-29.472]],"c":false}]},{"i":{"x":0.501,"y":1},"o":{"x":0.63,"y":0},"t":120,"s":[{"i":[[0,0],[-82.293,-13.259],[0,0],[-34.507,-8.177],[-21.832,21.441]],"o":[[4.828,52.466],[-6.704,-24.137],[22.346,11.668],[0.465,-12.358],[21.832,-21.441]],"v":[[-82.502,-54.481],[38.159,35.769],[24.918,-11.127],[116.84,23.477],[143.984,-57.363]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[{"i":[[0,0],[-82.293,-13.259],[0,0],[-34.507,-8.177],[-21.832,21.441]],"o":[[4.828,52.466],[-6.704,-24.137],[22.346,11.668],[0.465,-12.358],[21.832,-21.441]],"v":[[-82.502,-54.481],[38.159,35.769],[24.918,-11.127],[116.84,23.477],[143.984,-57.363]],"c":false}]},{"t":176,"s":[{"i":[[0,0],[-82.293,-13.259],[0,0],[-34.507,-8.177],[0,0]],"o":[[19.495,25.054],[-6.704,-24.137],[22.346,11.668],[0.465,-12.358],[0,0]],"v":[[-98.441,-37.98],[38.159,35.769],[24.918,-11.127],[116.84,23.477],[98.441,-29.472]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.396078431373,0.156862745098,0.192156862745,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":15},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":10}},{"n":"o","nm":"offset","v":{"a":0,"k":0}}],"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.576470588235,0.317647058824,0.356862745098,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"hair2_shd","parent":23,"sr":1,"ks":{"o":{"a":0,"k":25},"p":{"a":0,"k":[-27.411,-85.256,0]},"s":{"a":0,"k":[-100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.63,"y":0},"t":60,"s":[{"i":[[45.331,-31.142],[-53.146,3.12],[0,0],[0,0],[28.099,11.459]],"o":[[18.141,34.099],[2.526,-14.647],[30.792,12.953],[-7.304,-35.266],[-28.099,-11.459]],"v":[[-109.318,-43.404],[18.743,37.468],[29.946,-10.237],[98.222,18.99],[62.745,-47.77]],"c":true}]},{"i":{"x":0.501,"y":1},"o":{"x":0.63,"y":0},"t":120,"s":[{"i":[[45.331,-31.142],[-56.816,-23.894],[0,0],[0,0],[28.099,11.459]],"o":[[0.987,67.095],[2.526,-14.647],[17.031,34.482],[54.95,-80.464],[-28.099,-11.459]],"v":[[-120.841,-9.275],[-15.964,103.575],[-9.61,54.491],[56.834,116.818],[65.495,18.777]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[{"i":[[45.331,-31.142],[-56.816,-23.894],[0,0],[0,0],[28.099,11.459]],"o":[[0.987,67.095],[2.526,-14.647],[17.031,34.482],[54.95,-80.464],[-28.099,-11.459]],"v":[[-120.841,-9.275],[-15.964,103.575],[-9.61,54.491],[56.834,116.818],[65.495,18.777]],"c":true}]},{"t":176,"s":[{"i":[[45.331,-31.142],[-53.146,3.12],[0,0],[0,0],[28.099,11.459]],"o":[[18.141,34.099],[2.526,-14.647],[30.792,12.953],[-7.304,-35.266],[-28.099,-11.459]],"v":[[-109.318,-43.404],[18.743,37.468],[29.946,-10.237],[98.222,18.99],[62.745,-47.77]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.576470588235,0.317647058824,0.356862745098,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"mouth","parent":17,"sr":1,"ks":{"p":{"a":0,"k":[16.825,10.34,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.706,"y":0},"t":60,"s":[{"i":[[-6.229,5.137],[8.486,8.839],[-14.414,0.396]],"o":[[-8.049,7.529],[8.479,4.642],[17.407,-0.478]],"v":[[30.605,2.022],[-50.496,4.087],[-10.693,9.694]],"c":true}]},{"i":{"x":0.37,"y":1},"o":{"x":0.333,"y":0},"t":91,"s":[{"i":[[-6.229,5.137],[8.486,8.839],[-14.414,0.396]],"o":[[-8.049,7.529],[8.479,4.642],[17.407,-0.478]],"v":[[30.605,2.022],[-50.496,4.087],[-10.693,9.694]],"c":true}]},{"t":120,"s":[{"i":[[-6.229,5.137],[24.116,21.957],[-14.412,0.474]],"o":[[-15.061,16.959],[8.602,6.069],[26.191,-0.862]],"v":[[30.605,2.022],[-50.496,4.087],[-14.322,18.053]],"c":true}],"h":1},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[{"i":[[-6.229,5.137],[24.116,21.957],[-14.412,0.474]],"o":[[-15.061,16.959],[8.602,6.069],[26.191,-0.862]],"v":[[30.605,2.022],[-50.496,4.087],[-14.322,18.053]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":176,"s":[{"i":[[-6.229,5.137],[8.486,8.839],[-14.414,0.396]],"o":[[-8.049,7.529],[8.479,4.642],[17.407,-0.478]],"v":[[30.605,2.022],[-50.496,4.087],[-10.693,9.694]],"c":true}]},{"i":{"x":0.13,"y":1},"o":{"x":0.333,"y":0},"t":188,"s":[{"i":[[-6.229,5.137],[8.486,8.839],[-14.414,0.396]],"o":[[-8.049,7.529],[8.479,4.642],[17.407,-0.478]],"v":[[30.605,2.022],[-50.496,4.087],[-10.693,9.694]],"c":true}]},{"i":{"x":0.13,"y":1},"o":{"x":0.87,"y":0},"t":199,"s":[{"i":[[1.728,8.466],[1.564,-4.383],[-9.743,0.259]],"o":[[-1.723,-5.836],[-1.096,7.959],[11.766,-0.313]],"v":[[17.615,3.747],[-37.203,5.1],[-10.77,16.167]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.87,"y":0},"t":210,"s":[{"i":[[2.081,14.448],[1.883,-7.48],[-11.73,0.442]],"o":[[-2.075,-9.96],[-1.32,13.583],[14.166,-0.534]],"v":[[23.39,7.872],[-42.611,10.179],[-10.786,29.066]],"c":true}]},{"t":221,"s":[{"i":[[-6.229,5.137],[8.486,8.839],[-14.414,0.396]],"o":[[-8.049,7.529],[8.479,4.642],[17.407,-0.478]],"v":[[30.605,2.022],[-50.496,4.087],[-10.693,9.694]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.309803932905,0.039215687662,0.172549024224,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":12},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"nose","parent":17,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.706,"y":0.706},"t":60,"s":[13.7,-4.322,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.37,"y":1},"o":{"x":0.333,"y":0},"t":91,"s":[13.7,-4.322,0],"to":[0.364,2.134,0],"ti":[-0.364,-2.134,0]},{"t":120,"s":[15.884,8.481,0],"h":1},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[15.884,8.481,0],"to":[-0.695,-2.101,0],"ti":[1.807,1.996,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":176,"s":[11.713,-4.126,0],"to":[-1.807,-1.996,0],"ti":[1.111,-0.105,0]},{"i":{"x":0.37,"y":0.37},"o":{"x":0.333,"y":0.333},"t":202,"s":[5.044,-3.497,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.516,"y":1},"o":{"x":0.266,"y":0},"t":225,"s":[5.044,-3.497,0],"to":[1.443,-0.137,0],"ti":[-1.443,0.137,0]},{"t":238,"s":[13.7,-4.318,0]}]},"a":{"a":0,"k":[249.337,239.25,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-3.039],[5.118,0],[0,3.039],[-5.118,0]],"o":[[0,3.039],[-5.118,0],[0,-3.039],[5.118,0]],"v":[[9.267,0],[0,5.502],[-9.267,0],[0,-5.502]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.992156863213,0.713725507259,0.584313750267,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[248.36,235.413]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":30},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-7.32],[10.036,0],[0,7.32],[-10.036,0]],"o":[[0,7.32],[-10.036,0],[0,-7.32],[10.036,0]],"v":[[18.172,0],[0,13.255],[-18.172,0],[0,-13.255]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.309803932905,0.039215687662,0.172549024224,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[249.337,239.25]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"face","parent":23,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.37],"y":[1]},"o":{"x":[0.706],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.501],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":120,"s":[-18.529]},{"i":{"x":[0.37],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":156,"s":[-18.529]},{"t":202,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.706,"y":0},"t":60,"s":[-8.64,-2.25,0],"to":[34.245,1.817,0],"ti":[10.059,-20.406,0]},{"t":120,"s":[29.689,51.779,0],"h":1},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[29.92,52.231,0],"to":[-3.727,-13.689,0],"ti":[16.538,13.405,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":176,"s":[-8.64,-2.25,0],"to":[-3.492,-0.202,0],"ti":[0,0,0]},{"i":{"x":0.37,"y":0.37},"o":{"x":0.333,"y":0.333},"t":202,"s":[-21.171,-1.066,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.516,"y":1},"o":{"x":0.266,"y":0},"t":225,"s":[-21.171,-1.066,0],"to":[0,0,0],"ti":[0,0,0]},{"t":238,"s":[-8.64,-2.25,0]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-20.201],[24.915,0],[0,20.201],[-24.915,0]],"o":[[0,20.201],[-24.915,0],[0,-20.201],[24.915,0]],"v":[[51.604,0.34],[6.491,36.918],[-38.621,0.34],[6.491,-36.237]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.866666674614,0.568627476692,0.450980395079,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"eye_l_bl 2","parent":19,"sr":1,"ks":{"p":{"a":0,"k":[0.322,-13.299,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":88,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":95,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[4.547,25.469],[1.093,27.282],[-2.36,25.469],[1.093,23.656]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":149,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[4.547,25.469],[1.093,27.282],[-2.36,25.469],[1.093,23.656]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":156,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":184,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":191,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[2.933,6.232],[-0.52,8.045],[-3.974,6.232],[-0.52,4.419]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":225,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[2.933,6.232],[-0.52,8.045],[-3.974,6.232],[-0.52,4.419]],"c":true}]},{"t":232,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":88,"s":[1,1,1,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1.105],"y":[0]},"t":95,"s":[0.309803921569,0.039215686275,0.172549019608,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":149,"s":[0.309803932905,0.039215687662,0.172549024224,1]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":156,"s":[1,1,1,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":184,"s":[1,1,1,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":191,"s":[0.309803932905,0.039215687662,0.172549024224,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":225,"s":[0.309803932905,0.039215687662,0.172549024224,1]},{"t":232,"s":[1,1,1,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"eye_l 2","parent":17,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.706,"y":0},"t":60,"s":[74.898,-31.272,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.37,"y":1},"o":{"x":0.333,"y":0},"t":91,"s":[68.443,-32.493,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[74.898,-31.272,0],"h":1},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":156,"s":[74.898,-31.272,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":176,"s":[81.534,-31.617,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.37,"y":0.37},"o":{"x":0.333,"y":0.333},"t":202,"s":[81.534,-31.617,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.516,"y":1},"o":{"x":0.266,"y":0},"t":225,"s":[81.534,-31.617,0],"to":[0,0,0],"ti":[0,0,0]},{"t":238,"s":[74.898,-31.272,0]}]},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.706,0.706,0.706],"y":[0,0,0]},"t":60,"s":[110,100,100]},{"i":{"x":[0.37,0.37,0.37],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":91,"s":[100,100,100]},{"t":120,"s":[110,100,100],"h":1},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.63,0.63,0.63],"y":[0,0,0]},"t":156,"s":[110,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":176,"s":[115,100,100]},{"i":{"x":[0.37,0.37,0.37],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":202,"s":[120,100,100]},{"i":{"x":[0.516,0.516,0.516],"y":[1,1,1]},"o":{"x":[0.266,0.266,0.266],"y":[0,0,0]},"t":225,"s":[115,100,100]},{"t":238,"s":[110,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":88,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":95,"s":[{"i":[[10.121,0],[-3.284,5.575],[-10.121,0],[-3.89,-6.384]],"o":[[-10.121,0],[2.426,-4.119],[10.121,0],[2.598,4.264]],"v":[[-0.816,15.825],[-18.326,0],[-1.093,8.075],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":149,"s":[{"i":[[10.121,0],[-3.284,5.575],[-10.121,0],[-3.89,-6.384]],"o":[[-10.121,0],[2.426,-4.119],[10.121,0],[2.598,4.264]],"v":[[-0.816,15.825],[-18.326,0],[-1.093,8.075],[18.326,0]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":156,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":184,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":191,"s":[{"i":[[10.121,0],[0.331,4.518],[-10.121,0],[0.437,-4.597]],"o":[[-10.121,0],[-0.187,-2.557],[10.121,0],[-0.472,4.97]],"v":[[-0.645,-3.264],[-18.326,0],[-0.922,-11.014],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":225,"s":[{"i":[[10.121,0],[0.331,4.518],[-10.121,0],[0.437,-4.597]],"o":[[-10.121,0],[-0.187,-2.557],[10.121,0],[-0.472,4.97]],"v":[[-0.645,-3.264],[-18.326,0],[-0.922,-11.014],[18.326,0]],"c":true}]},{"t":232,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.309803932905,0.039215687662,0.172549024224,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"st","c":{"a":0,"k":[0.309803932905,0.039215687662,0.172549024224,1]},"o":{"a":0,"k":100},"w":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":88,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.308],"y":[0]},"t":95,"s":[6]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":149,"s":[6]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":156,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":184,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.308],"y":[0]},"t":191,"s":[9]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.308],"y":[0]},"t":225,"s":[9]},{"t":232,"s":[0]}]},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"eye_l_bl","parent":21,"sr":1,"ks":{"p":{"a":0,"k":[0.322,-13.299,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":88,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":95,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[4.547,25.469],[1.093,27.282],[-2.36,25.469],[1.093,23.656]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":149,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[4.547,25.469],[1.093,27.282],[-2.36,25.469],[1.093,23.656]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":156,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":184,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":191,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[2.933,6.232],[-0.52,8.045],[-3.974,6.232],[-0.52,4.419]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":225,"s":[{"i":[[0,-1.001],[1.907,0],[0,1.001],[-1.907,0]],"o":[[0,1.001],[-1.907,0],[0,-1.001],[1.907,0]],"v":[[2.933,6.232],[-0.52,8.045],[-3.974,6.232],[-0.52,4.419]],"c":true}]},{"t":232,"s":[{"i":[[0,-2.933],[3.403,0],[0,2.933],[-3.403,0]],"o":[[0,2.933],[-3.403,0],[0,-2.933],[3.403,0]],"v":[[6.161,0],[0,5.311],[-6.161,0],[0,-5.311]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":88,"s":[1,1,1,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1.105],"y":[0]},"t":95,"s":[0.309803921569,0.039215686275,0.172549019608,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":149,"s":[0.309803932905,0.039215687662,0.172549024224,1]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":156,"s":[1,1,1,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":184,"s":[1,1,1,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":191,"s":[0.309803932905,0.039215687662,0.172549024224,1]},{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":225,"s":[0.309803932905,0.039215687662,0.172549024224,1]},{"t":232,"s":[1,1,1,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"eye_l","parent":17,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":176,"s":[-68.367,-22.554,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.37,"y":0.37},"o":{"x":0.333,"y":0.333},"t":202,"s":[-68.367,-22.554,0],"to":[0,0,0],"ti":[0,0,0]},{"t":225,"s":[-68.367,-22.554,0]}]},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":176,"s":[120,100,100]},{"i":{"x":[0.37,0.37,0.37],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":202,"s":[110,100,100]},{"t":225,"s":[120,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":88,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":95,"s":[{"i":[[10.121,0],[-3.284,5.575],[-10.121,0],[-3.89,-6.384]],"o":[[-10.121,0],[2.426,-4.119],[10.121,0],[2.598,4.264]],"v":[[-0.816,15.825],[-18.326,0],[-1.093,8.075],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":149,"s":[{"i":[[10.121,0],[-3.284,5.575],[-10.121,0],[-3.89,-6.384]],"o":[[-10.121,0],[2.426,-4.119],[10.121,0],[2.598,4.264]],"v":[[-0.816,15.825],[-18.326,0],[-1.093,8.075],[18.326,0]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":156,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":184,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":191,"s":[{"i":[[10.121,0],[0.331,4.518],[-10.121,0],[0.437,-4.597]],"o":[[-10.121,0],[-0.187,-2.557],[10.121,0],[-0.472,4.97]],"v":[[-0.645,-3.264],[-18.326,0],[-0.922,-11.014],[18.326,0]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":225,"s":[{"i":[[10.121,0],[0.331,4.518],[-10.121,0],[0.437,-4.597]],"o":[[-10.121,0],[-0.187,-2.557],[10.121,0],[-0.472,4.97]],"v":[[-0.645,-3.264],[-18.326,0],[-0.922,-11.014],[18.326,0]],"c":true}]},{"t":232,"s":[{"i":[[10.121,0],[0,13.876],[-10.121,0],[0,-13.876]],"o":[[-10.121,0],[0,-13.876],[10.121,0],[0,13.876]],"v":[[0,25.125],[-18.326,0],[0,-25.125],[18.326,0]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.309803932905,0.039215687662,0.172549024224,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"st","c":{"a":0,"k":[0.309803932905,0.039215687662,0.172549024224,1]},"o":{"a":0,"k":100},"w":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":88,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.308],"y":[0]},"t":95,"s":[6]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":149,"s":[6]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":156,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":184,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.308],"y":[0]},"t":191,"s":[10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.308],"y":[0]},"t":225,"s":[10]},{"t":232,"s":[0]}]},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":22,"ty":4,"nm":"head_bl 2","parent":23,"sr":1,"ks":{"r":{"a":0,"k":2.883},"p":{"a":0,"k":[-131.293,9.257,0]},"a":{"a":0,"k":[-160.25,-87.873,0]},"s":{"a":0,"k":[108.543,104.308,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.63,"y":0},"t":60,"s":[{"i":[[0,0],[-6.282,22.107]],"o":[[-2.3,-29.201],[0,0]],"v":[[-162.766,-77.131],[-160.084,-151.156]],"c":false}]},{"i":{"x":0.37,"y":1},"o":{"x":0.63,"y":0},"t":120,"s":[{"i":[[0,0],[-5.198,22.387]],"o":[[-3.719,-29.054],[0,0]],"v":[[-158.291,-50.839],[-159.216,-124.907]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":146,"s":[{"i":[[0,0],[-5.198,22.387]],"o":[[-3.719,-29.054],[0,0]],"v":[[-158.291,-50.839],[-159.216,-124.907]],"c":false}]},{"t":185,"s":[{"i":[[0,0],[-6.282,22.107]],"o":[[-2.3,-29.201],[0,0]],"v":[[-162.766,-77.131],[-160.084,-151.156]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.859420955882,0.822748161765,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":15},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":50}},{"n":"g","nm":"gap","v":{"a":0,"k":20}},{"n":"o","nm":"offset","v":{"a":0,"k":0}}],"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":23,"ty":4,"nm":"body","parent":9,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":62,"s":[3]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":80,"s":[-8.899]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":120,"s":[27.265]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":148,"s":[27.265]},{"i":{"x":[0.667],"y":[0.726]},"o":{"x":[0.333],"y":[0]},"t":187,"s":[6.682]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[-0.329]},"t":219,"s":[-1]},{"t":239,"s":[3]}]},"p":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[73.963,-36.799,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.37,"y":1},"o":{"x":0.709,"y":0},"t":95,"s":[9.271,-36.799,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":146,"s":[9.271,-36.799,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.621,"y":1},"o":{"x":0.333,"y":0},"t":185,"s":[73.79,-36.799,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.621,"y":1},"o":{"x":0.167,"y":0},"t":215,"s":[49.271,-36.799,0],"to":[0,0,0],"ti":[0,0,0]},{"t":236,"s":[73.963,-36.799,0]}]},"a":{"a":0,"k":[0,144.412,0]},"s":{"a":0,"k":[92.12,95.88,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.63,"y":0},"t":60,"s":[{"i":[[-63.433,0],[-22.906,-74.005],[156.821,0],[-19.858,69.514]],"o":[[76.649,0],[22.906,74.005],[-156.821,0],[19.858,-69.514]],"v":[[-12.772,-144.055],[124.666,-54.191],[-10.129,144.919],[-148.044,-52.516]],"c":true}]},{"i":{"x":0.37,"y":1},"o":{"x":0.63,"y":0},"t":120,"s":[{"i":[[-58.459,24.624],[-48.741,-70.087],[148.256,-40.322],[-13.943,59.414]],"o":[[57.125,-24.062],[44.231,63.602],[-151.324,41.156],[16.518,-70.382]],"v":[[-7.154,-74.134],[114.467,-54.935],[18.121,152.051],[-147.744,-15.334]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":146,"s":[{"i":[[-58.459,24.624],[-48.741,-70.087],[148.256,-40.322],[-13.943,59.414]],"o":[[57.125,-24.062],[44.231,63.602],[-151.324,41.156],[16.518,-70.382]],"v":[[-7.154,-74.134],[114.467,-54.935],[18.121,152.051],[-147.744,-15.334]],"c":true}]},{"i":{"x":0.37,"y":1},"o":{"x":0.333,"y":0},"t":185,"s":[{"i":[[-63.433,0],[-22.906,-74.005],[156.821,0],[-19.858,69.514]],"o":[[76.649,0],[22.906,74.005],[-156.821,0],[19.858,-69.514]],"v":[[-12.772,-144.055],[124.666,-54.191],[-10.129,144.919],[-148.044,-52.516]],"c":true}]},{"t":215,"s":[{"i":[[-63.433,0],[-22.906,-74.005],[156.821,0],[-19.858,69.514]],"o":[[76.649,0],[22.906,74.005],[-156.821,0],[19.858,-69.514]],"v":[[-12.772,-144.055],[124.666,-54.191],[-10.129,144.919],[-148.044,-52.516]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"gf","o":{"a":0,"k":100},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,0.784,0.702,0.5,0.933,0.676,0.576,1,0.867,0.569,0.451]}},"s":{"a":0,"k":[-0.354,49.872]},"e":{"a":0,"k":[-0.354,135.165]},"t":1,"nm":"Gradient Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":24,"ty":4,"nm":"leg_r","parent":9,"sr":1,"ks":{"p":{"a":0,"k":[103.516,60,0]},"a":{"a":0,"k":[-6.618,51.641,0]},"s":{"a":0,"k":[94,94,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-9.132,-79.977],[-4.995,45.551],[4.995,51.641]],"c":false}]},{"i":{"x":0.37,"y":1},"o":{"x":0.709,"y":0},"t":95,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-72.242,-81.153],[-4.995,45.551],[4.995,51.641]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":146,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-65.186,-75.656],[-4.995,45.551],[4.995,51.641]],"c":false}]},{"i":{"x":0.621,"y":1},"o":{"x":0.333,"y":0},"t":185,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[4.494,-79.321],[-4.995,45.551],[4.995,51.641]],"c":false}]},{"i":{"x":0.621,"y":1},"o":{"x":0.167,"y":0},"t":215,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-40.653,-79.977],[-4.995,45.551],[4.995,51.641]],"c":false}]},{"t":236,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-9.132,-79.977],[-4.995,45.551],[4.995,51.641]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.866666674614,0.568627476692,0.450980395079,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":25,"ty":4,"nm":"leg_l","parent":9,"sr":1,"ks":{"p":{"a":0,"k":[16.484,60,0]},"a":{"a":0,"k":[0.764,51.641,0]},"s":{"a":0,"k":[94,94,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.37,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[16.543,-58.024],[6.068,45.551],[-6.068,51.641]],"c":false}]},{"i":{"x":0.37,"y":1},"o":{"x":0.709,"y":0},"t":95,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-30.634,-71.854],[6.068,45.551],[-6.068,51.641]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.63,"y":0},"t":146,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-30.634,-71.854],[6.068,45.551],[-6.068,51.641]],"c":false}]},{"i":{"x":0.621,"y":1},"o":{"x":0.333,"y":0},"t":185,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[29.493,-58.024],[6.068,45.551],[-6.068,51.641]],"c":false}]},{"i":{"x":0.621,"y":1},"o":{"x":0.167,"y":0},"t":215,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-9.725,-58.024],[6.068,45.551],[-6.068,51.641]],"c":false}]},{"t":236,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[16.543,-58.024],[6.068,45.551],[-6.068,51.641]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.866666674614,0.568627476692,0.450980395079,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":26,"ty":4,"nm":"hand_r","parent":23,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[-13.673]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":75,"s":[-168.873]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":105,"s":[-135.411]},{"i":{"x":[0.175],"y":[1.008]},"o":{"x":[0.333],"y":[0]},"t":146,"s":[-135.411]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":166,"s":[-168.873]},{"t":178,"s":[-13.673]}]},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[114.202,9.222,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":75,"s":[113.466,-31.332,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":166,"s":[113.466,-31.332,0],"to":[0,0,0],"ti":[0,0,0]},{"t":178,"s":[114.202,9.222,0]}]},"a":{"a":0,"k":[-10.419,-48.387,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[{"i":[[0,0],[-3.272,-40.846]],"o":[[-9.914,43.85],[-4.241,11.341]],"v":[[-10.523,-48.561],[-22.803,80.6]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[{"i":[[0,0],[-41.107,-24.076]],"o":[[-77.807,54.677],[-4.241,11.341]],"v":[[-10.523,-48.561],[-12.305,88.908]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":91,"s":[{"i":[[0,0],[-47.167,-11.272]],"o":[[-41.163,57.169],[-4.241,11.341]],"v":[[-10.523,-48.561],[15.365,93.495]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":105,"s":[{"i":[[0,0],[-25.613,-31.44]],"o":[[-18.998,63.335],[-4.241,11.341]],"v":[[-10.523,-48.561],[22.447,95.936]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[-23.369,-38.821]],"o":[[-24.1,51.417],[-4.241,11.341]],"v":[[-10.523,-48.561],[-1.754,91.042]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":146,"s":[{"i":[[0,0],[-23.369,-38.821]],"o":[[-24.1,51.417],[-4.241,11.341]],"v":[[-10.523,-48.561],[-1.754,91.042]],"c":false}]},{"i":{"x":0.175,"y":0.175},"o":{"x":0.167,"y":0.167},"t":156,"s":[{"i":[[0,0],[-32.238,-31.449]],"o":[[-35.401,40.797],[-4.241,11.341]],"v":[[-10.523,-48.561],[-48.945,111.4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":166,"s":[{"i":[[0,0],[-41.107,-24.076]],"o":[[-46.702,30.177],[-4.241,11.341]],"v":[[-10.523,-48.561],[-53.85,83.556]],"c":false}]},{"t":178,"s":[{"i":[[0,0],[17.119,-46.344]],"o":[[12.297,27.477],[-4.241,11.341]],"v":[[-10.523,-48.561],[-12.305,88.908]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.933333393172,0.678431372549,0.580392156863,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":27,"ty":4,"nm":"hair_bl","parent":28,"sr":1,"ks":{"o":{"a":0,"k":44},"p":{"a":0,"k":[-44.09,-42.037,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[206.729,170.081],[183.272,88.476],[250.903,95.763],[205.936,25.462],[254.24,7.588],[195.211,-41.346],[226.955,-71.937],[164.637,-100.615],[168.947,-134.892],[109.496,-137.529],[88.228,-172.824]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.858823529412,0.501960784314,0.341176470588,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":13},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"tm","s":{"a":0,"k":0},"e":{"a":1,"k":[{"i":{"x":[0.37],"y":[1]},"o":{"x":[0.706],"y":[0]},"t":64,"s":[64]},{"t":100,"s":[100],"h":1},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.63],"y":[0]},"t":156,"s":[100]},{"t":162,"s":[64]}]},"o":{"a":0,"k":0},"m":1,"nm":"Trim Paths 1","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":28,"ty":4,"nm":"hair1","parent":23,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":48,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":68,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":71,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":89,"s":[-10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":129,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":157,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":196,"s":[-10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":228,"s":[-5]},{"t":248,"s":[-5]}]},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.63,"y":0.63},"t":156,"s":[-14.561,-5.151,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":176,"s":[-14.561,-5.151,0],"to":[1.556,-0.147,0],"ti":[-1.556,0.147,0]},{"i":{"x":0.37,"y":0.37},"o":{"x":0.333,"y":0.333},"t":193,"s":[-5.225,-6.033,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.516,"y":1},"o":{"x":0.266,"y":0},"t":225,"s":[-5.225,-6.033,0],"to":[-1.556,0.147,0],"ti":[1.556,-0.147,0]},{"t":238,"s":[-14.561,-5.151,0]}]},"s":{"a":0,"k":[-100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[24.061,-182.722],[56.629,-181.785],[70.317,-182.976],[111.248,-170.82],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-108.365,-153.253],[-61.545,-160.917],[-19.035,-172.35]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[24.061,-182.722],[60.541,-193.261],[74.23,-194.452],[141.12,-191.15],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-108.365,-153.253],[-61.545,-160.917],[-19.035,-172.35]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[0,-210.23],[50.915,-239.926],[74.23,-194.452],[141.12,-191.15],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-134.22,-191.155],[-74.229,-194.452],[-46.913,-239.092]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[0,-210.23],[52.693,-231.976],[74.23,-194.452],[141.12,-191.15],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-134.22,-191.155],[-74.229,-194.452],[-45.135,-231.142]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":156,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[0,-210.23],[52.693,-231.976],[74.23,-194.452],[141.12,-191.15],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-134.22,-191.155],[-74.229,-194.452],[-45.135,-231.142]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":167,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[13.829,-194.42],[55.032,-205.723],[73.154,-189.649],[111.191,-162.773],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-119.36,-169.371],[-66.939,-175.178],[-30.134,-197.351]],"c":true}]},{"t":176,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[24.061,-182.722],[56.763,-186.299],[72.359,-186.095],[126.675,-173.67],[135.624,-149.846],[203.324,-118.94],[173.568,-84.126],[238.365,-28.758],[181.5,-8.654],[237.517,71.487],[158.05,63.52],[188.186,156.93],[107.271,119.916],[108.367,214.922],[37.944,150.782],[11.862,235.435],[-37.944,150.782],[-84.644,214.922],[-107.271,119.916],[-164.463,156.93],[-158.05,63.52],[-213.794,71.487],[-181.5,-8.654],[-224.107,-26.634],[-173.568,-84.126],[-205.265,-125.344],[-135.624,-149.846],[-108.365,-153.253],[-61.545,-160.917],[-19.035,-172.35]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.396078431373,0.156862745098,0.192156862745,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":15},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":10}},{"n":"o","nm":"offset","v":{"a":0,"k":0}}],"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.576470588235,0.317647058824,0.356862745098,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":29,"ty":4,"nm":"SHD","sr":1,"ks":{"o":{"a":0,"k":10},"p":{"a":0,"k":[256.455,481.455,0]},"a":{"a":0,"k":[-50.545,-56.545,0]},"s":{"a":0,"k":[100,30,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[204.91,204.91]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","hd":false},{"ty":"gf","o":{"a":0,"k":100},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0,0,0,0.5,0,0,0,0.999,0,0,0,0,1,0.066,1,0.33,1,0.665,0.5,1,0]}},"s":{"a":0,"k":[0,0]},"e":{"a":0,"k":[100,0]},"t":2,"h":{"a":0,"k":0},"a":{"a":0,"k":0},"nm":"ggg","hd":false},{"ty":"tr","p":{"a":0,"k":[-50.545,-56.545]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Ellipse 1","bm":0,"hd":false}],"ip":0,"op":300,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"10_HELLO","parent":2,"refId":"comp_0","sr":1,"ks":{"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[256,256,0]}},"ao":0,"w":512,"h":512,"ip":124,"op":304,"st":64,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"10_HELLO","refId":"comp_0","sr":1,"ks":{"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[256,256,0]},"s":{"a":0,"k":[-100,100,100]}},"ao":0,"w":512,"h":512,"ip":-56,"op":124,"st":-116,"bm":0}]} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 022a57b..5174483 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,23 @@ -import './App.css' -import {BrowserRouter, Route, Routes} from "react-router-dom"; -import Home from "./pages/Home/Home.tsx" -import TournamentsPage from "./pages/TournamentsPage/TournamentsPage.tsx"; -import TournamentPage from "./pages/TournamentPage/TournamentPage.tsx"; -import Profile from "./pages/Profile/Profile.tsx"; -import Auth from "./pages/Auth/Auth.tsx"; -import Page404 from "./pages/Page404/Page404.tsx"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; -function App() { - return( - - - } /> - } /> - } /> - } /> - } /> - } /> - - - ) -} +import { Home } from "./pages/Home/Home"; +import { Auth } from "./pages/Auth/Auth"; +import { Profile } from "./pages/Profile/Profile"; +import { TournamentsPage } from "./pages/TournamentsPage/TournamentsPage"; +import { TournamentPage } from "./pages/TournamentPage/TournamentPage"; +import { Page404 } from "./pages/Page404/Page404"; -export default App +export const App = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..b54fdcc --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,110 @@ +import { Link } from "react-router-dom"; + +export const Footer = () => { + return ( +
+
+
+
+
+ + UGalaxy x Star for Life + +

+ Місце, де народжуються найкращі ідеї. Твори, навчайся, + перемагай. +

+
+ +
+

+ Платформа +

+
    +
  • + + Всі завдання + +
  • +
  • + + Рейтинг учасників + +
  • +
  • + + Правила + +
  • +
  • + + FAQ + +
  • +
+
+ +
+

+ Спільнота +

+ +
+
+ +
+

© 2026 UGalaxy x Star for Life. Всі права захищено.

+
+
+
+
+ ); +}; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..64980fd --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,52 @@ +import { Link, NavLink } from "react-router-dom"; + +export const Header = () => { + const navItems = [ + { path: "/tournaments", label: "Турніри" }, + { path: "/projects", label: "Проєкти" }, + { path: "/join", label: "Як долучитись" }, + { path: "/rating", label: "Рейтинг" }, + ]; + + return ( +
+
+ + UGalaxy x Star for Life + + + + + + Увійти + +
+
+ ); +}; diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx new file mode 100644 index 0000000..23202a0 --- /dev/null +++ b/frontend/src/components/Hero.tsx @@ -0,0 +1,112 @@ +import { type ReactNode } from "react"; +import { Player } from "@lottiefiles/react-lottie-player"; +import { Link } from "react-router-dom"; + +interface Badge { + text: string; + className: string; +} + +interface MascotProps { + circularText: string; + lottieSrc: string; + buttonText: string; + buttonLink: string; +} + +interface HeroProps { + bgText: string; + title: ReactNode; + description: string; + badges?: Badge[]; + mascot?: MascotProps; +} + +export const Hero = ({ + bgText, + title, + description, + badges = [], + mascot, +}: HeroProps) => { + return ( +
+
+ + {bgText} ★ {bgText} ★  + + + {bgText} ★ {bgText} ★  + +
+ + {badges.map((badge, index) => ( +
+ {badge.text} +
+ ))} + +
+

+ {title} +

+ +

+ {description} +

+
+ + {mascot && ( +
+
+ + + + {mascot.circularText} + + +
+ + + + + {mascot.buttonText} + +
+ )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/src/components/TournamentCard.tsx b/frontend/src/components/TournamentCard.tsx new file mode 100644 index 0000000..0c8c5ad --- /dev/null +++ b/frontend/src/components/TournamentCard.tsx @@ -0,0 +1,92 @@ +import React from "react"; + +interface Tag { + label: string; + type: "accent" | "light" | "pink" | "custom"; + customStyle?: React.CSSProperties; +} + +interface TournamentCardProps { + title: string; + description: string; + tags: Tag[]; + buttonText: string; + buttonClass: string; + cardStyle?: React.CSSProperties; + buttonStyle?: React.CSSProperties; +} + +const tagColors = { + accent: "bg-accent text-slate-900", + light: "bg-slate-100 text-slate-500", + pink: "bg-pink-100 text-pink-700", + custom: "", +}; + +export const TournamentCard = ({ + title, + description, + tags, + buttonText, + buttonClass, + cardStyle, + buttonStyle, +}: TournamentCardProps) => { + const isDefaultWhiteCard = !cardStyle?.backgroundColor; + const isOutline = buttonClass.includes("btn-outline"); + + return ( +
+
+
+ {tags.map((tag, i) => ( + + {tag.label} + + ))} +
+ +

+ {title} +

+ +

+ {description} +

+
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..8252aa6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,68 +1,85 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@import "tailwindcss"; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +@theme { + --font-inter: "Inter", sans-serif; + --font-quicksand: "Quicksand", sans-serif; + --color-primary: #6366f1; + --color-accent: #fbbf24; + --color-bg-body: #f8fafc; + --color-bg-card: #ffffff; + --color-dark-theme: #1e293b; + --color-pink-accent: #ec4899; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} + --animate-marquee: marquee 40s linear infinite; -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; + @keyframes marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } + } } -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; +@layer base { + body { + @apply font-inter bg-primary text-white overflow-x-hidden leading-[1.5]; + } + h1, + h2, + h3, + h4 { + @apply font-quicksand font-extrabold; + } } -h1 { - font-size: 3.2em; - line-height: 1.1; +@layer utilities { + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + .no-scrollbar::-webkit-scrollbar { + display: none; + } } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} +@layer components { + .btn { + @apply inline-flex items-center justify-center px-9 py-4 rounded-full font-quicksand font-bold text-[18px] cursor-pointer no-underline border-none transition-all duration-200; + } + .btn-accent { + @apply bg-accent text-slate-900 hover:-translate-y-1 hover:scale-105 shadow-[0_10px_20px_rgba(251,191,36,0.4)]; + } + .btn-primary { + @apply bg-primary text-white hover:-translate-y-1 hover:scale-105 shadow-[0_10px_20px_rgba(99,102,241,0.4)]; + } + .btn-dark { + @apply bg-dark-theme text-white hover:-translate-y-1 hover:scale-105; + } + .btn-outline { + @apply bg-transparent text-white border-2 border-white/40 hover:bg-white/10; + } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + .tournaments { + @apply bg-bg-body text-dark-theme relative py-[100px] pt-[160px]; + } + .section-header { + @apply px-[5%] mb-[60px] flex justify-between items-end; + } + .section-header h2 { + @apply text-[64px] leading-[1] uppercase; + } + .slider-area { + @apply relative w-full overflow-hidden pb-5; + } + .cards-track { + @apply flex gap-8 px-[5%] py-5 w-max cursor-grab active:cursor-grabbing; + } + .custom-scrollbar { + @apply mx-[5%] mt-10 h-3 bg-slate-200 rounded-full relative; + } + .scroll-thumb { + @apply absolute top-0 left-0 h-full w-[150px] bg-primary rounded-full cursor-grab transition-colors active:cursor-grabbing active:bg-indigo-600; + } } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..861daaf 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import { App } from "./App"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/frontend/src/pages/Auth/Auth.tsx b/frontend/src/pages/Auth/Auth.tsx index d7bcc3c..f2e62df 100644 --- a/frontend/src/pages/Auth/Auth.tsx +++ b/frontend/src/pages/Auth/Auth.tsx @@ -1,5 +1,3 @@ -function Auth() { - return (

Сторінка для логіну та реєстрації

) -} - -export default Auth; \ No newline at end of file +export const Auth = () => { + return

pahe aut

; +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 09edc0f..2b7e7fc 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,9 +1,62 @@ -function Home(){ - return ( - <> -

Головна сторінка

- - ) -} +import { Header } from "../../components/Header"; +import { Footer } from "../../components/Footer"; +import { Hero } from "../../components/Hero"; +import { TournamentSlider } from "./components/TournamentSlider"; -export default Home; \ No newline at end of file +export const Home = () => { + return ( + <> +
+
+ + Твори. +
+ Дій. +
+ Перемагай. + + } + description="Платформа для твоїх найсміливіших ідей. Від написання коду та дизайну до мистецтва й креативу — збирай команду, розкривай свій талант, ділися досвідом, набувай його і рухайся до вершини!" + badges={[ + { + text: "🔥 Прояви себе!", + className: + "bottom-[35%] left-[2vw] xl:left-[10vw] bg-dark-theme text-white -rotate-6", + }, + { + text: "💡 Твоя ідея змінить світ", + className: + "top-[15%] right-[2vw] xl:right-[8vw] bg-accent text-slate-900 rotate-3 text-[22px]", + }, + { + text: "🚀 Дій зараз", + className: + "bottom-[20%] right-[4vw] xl:right-[12vw] bg-pink-accent text-white -rotate-3", + }, + { + text: "🍕 Піца, код, перемога", + className: + "top-[25%] left-[5vw] xl:left-[12vw] bg-primary text-white rotate-6 border-2 border-white/20", + }, + { + text: "🤘 Будь собою!", + className: + "bottom-[50%] right-[1vw] xl:right-[5vw] bg-white text-dark-theme -rotate-12", + }, + ]} + mascot={{ + circularText: "★ ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ", + lottieSrc: "/hedgehog.json", + buttonText: "Долучитись", + buttonLink: "/register", + }} + /> + +
+
+ + ); +}; diff --git a/frontend/src/pages/Home/components/TournamentSlider.tsx b/frontend/src/pages/Home/components/TournamentSlider.tsx new file mode 100644 index 0000000..c1c505e --- /dev/null +++ b/frontend/src/pages/Home/components/TournamentSlider.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useRef, useCallback } from "react"; +import { TournamentCard } from "../../../components/TournamentCard"; + +const CARDS_DATA = [ + { + title: "Турнір бравл старс", + description: + "Бла-бла-бла, леон бравл старс, мільйон гемів і т.д. БАГААААААААТО ТЕКСТУУУУУУУУУУУУУУУ", + buttonText: "Показати скіл", + buttonClass: "btn-primary", + tags: [ + { label: "Битва", type: "accent" as const }, + { label: "Екшин", type: "pink" as const }, + ], + }, + { + title: "24 години челендж", + description: + "Любите поїсти? Ми пропонуємо турнір, протягом 24 годин ви повинні з'їсти 100 пачок картоплі фрі.", + buttonText: "ням-ням", + buttonClass: "btn-outline", + buttonStyle: { + color: "var(--color-primary)", + borderColor: "var(--color-primary)", + }, + tags: [ + { + label: "Їжа", + type: "custom" as const, + customStyle: { background: "var(--color-primary)", color: "white" }, + }, + { label: "Макдональдс", type: "light" as const }, + ], + }, + { + title: "Бокс у костюмах динозаврів", + description: + "Звичайні бойові мистецтва це надто серйозно. А от вийти на ринг, будучи двометровим надувним тиранозавром весело, вхвххв", + buttonText: "Ррррр!", + buttonClass: "btn-accent", + cardStyle: { backgroundColor: "#14532d", color: "white" }, + tags: [ + { + label: "Спорт", + type: "custom" as const, + customStyle: { background: "rgba(255, 255, 255, 0.2)", color: "white" }, + }, + { label: "T-Rex", type: "accent" as const }, + ], + }, + { + title: "Забіг синіх їжаків", + description: + "Одягаємо колючі сині перуки, червоні кросівки і біжимо на впередки, збираючи розкидані металеві кільця. Головне правило - не врізатися в дерева на надзвуковій швидкості, на страховку грошей нема.", + buttonText: "ГААААЗУЙ", + buttonClass: "btn-primary", + cardStyle: { + backgroundColor: "#1e3a8a", + color: "white", + border: "2px solid #fbbf24", + }, + tags: [ + { label: "Біг", type: "accent" as const }, + { label: "Розваги", type: "light" as const }, + ], + }, + { + title: "Екстремальний продаж", + description: + "Ви на міському ярмарку. Ваша мета - продати багато плетених вушиків за дві години людям, які прийшли 'просто подивитися'. Дозволено використовувати будь-які методи переконання.", + buttonText: "Підписатись", + buttonClass: "btn-outline", + buttonStyle: { + color: "#d97706", + borderColor: "#d97706", + }, + tags: [ + { + label: "Бізнес", + type: "custom" as const, + customStyle: { background: "#d97706", color: "white" }, + }, + { label: "Нерви", type: "pink" as const }, + ], + }, + { + title: "Лабораторія геніальних сестер", + description: + "Вас замикають у кімнаті з купою незрозумілих хімікатів, дивними винаходами та собакою, що розмовляє. Завдання: зробити зілля перетворення на халка і втекти", + buttonText: "В путь", + buttonClass: "btn-primary", + tags: [ + { label: "Хімія", type: "pink" as const }, + { label: "Веселощі", type: "accent" as const }, + ], + }, + { + title: "Ідей більше нема", + description: + "Ех, мені лінь далі думати, просто текст на відчепись, аби було гарно і красиво, усьо", + buttonText: "Реєстрація", + buttonClass: "btn-primary", + tags: [ + { label: "Ідеї", type: "light" as const }, + { label: "Думи", type: "light" as const }, + ], + }, +]; + +export const TournamentSlider = () => { + const sliderRef = useRef(null); + const progressBarRef = useRef(null); + + const isPaused = useRef(false); + const progressRef = useRef(0); + const lastTimeRef = useRef(0); + const requestRef = useRef(0); + + const DURATION = 5000; + const GAP = 24; + + const getScrollAmount = useCallback(() => { + if (!sliderRef.current) return 0; + const card = sliderRef.current.querySelector(".tournament-card-wrapper"); + if (!card) return 434; + return card.clientWidth + GAP; + }, []); + + const handleNext = useCallback(() => { + if (!sliderRef.current) return; + const slider = sliderRef.current; + const scrollAmount = getScrollAmount(); + + if (slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 20) { + slider.scrollTo({ left: 0, behavior: "smooth" }); + } else { + slider.scrollBy({ left: scrollAmount, behavior: "smooth" }); + } + progressRef.current = 0; + }, [getScrollAmount]); + + const handlePrev = useCallback(() => { + if (!sliderRef.current) return; + const slider = sliderRef.current; + const scrollAmount = getScrollAmount(); + + if (slider.scrollLeft <= 10) { + slider.scrollTo({ left: slider.scrollWidth, behavior: "smooth" }); + } else { + slider.scrollBy({ left: -scrollAmount, behavior: "smooth" }); + } + progressRef.current = 0; + }, [getScrollAmount]); + + useEffect(() => { + const updateAnimation = (time: number) => { + if (!lastTimeRef.current) lastTimeRef.current = time; + const deltaTime = time - lastTimeRef.current; + lastTimeRef.current = time; + + if (!isPaused.current) { + progressRef.current += (deltaTime / DURATION) * 100; + if (progressRef.current >= 100) { + handleNext(); + progressRef.current = 0; + } + if (progressBarRef.current) { + progressBarRef.current.style.width = `${progressRef.current}%`; + } + } + requestRef.current = requestAnimationFrame(updateAnimation); + }; + + requestRef.current = requestAnimationFrame(updateAnimation); + return () => cancelAnimationFrame(requestRef.current); + }, [handleNext]); + + return ( +
+
+
+

+ Обери свій напрямок +

+ +
+ +
(isPaused.current = true)} + onMouseLeave={() => (isPaused.current = false)} + > + + +
+
+ {CARDS_DATA.map((card, index) => ( +
+ +
+ ))} +
+
+
+ + +
+ +
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Page404/Page404.tsx b/frontend/src/pages/Page404/Page404.tsx index 212d1a1..288ebcc 100644 --- a/frontend/src/pages/Page404/Page404.tsx +++ b/frontend/src/pages/Page404/Page404.tsx @@ -1,39 +1,43 @@ -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from "react-router-dom"; -function Page404() { - const navigate = useNavigate(); +export const Page404 = () => { + const navigate = useNavigate(); - return ( -
-

- 404 -

+ return ( +
+
-
-

- Схоже, ви загубилися... -

-

- Ця сторінка або пішла у відпустку, або її ніколи не існувало. - Не хвилюйтеся, ми допоможемо вам повернутися. -

-
-
- - -
+
+

+ 404 +

+ +
+

+ Ви вийшли за межі системи 👾 +

+

+ Сторінку, яку ви шукаєте, було видалено, або вона існує лише в + паралельному всесвіті. Давайте повернемося на безпечну територію. +

- ); -}; -export default Page404; \ No newline at end of file +
+ + + + На головну + +
+
+
+ ); +}; diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 95593d6..e0b5821 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,5 +1,3 @@ -function Profile(){ - return (

Сторінка для профілю

) -} - -export default Profile; \ No newline at end of file +export const Profile = () => { + return

Сторінка для профілю

; +}; diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx index 7651841..78e91d4 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -1,5 +1,346 @@ -function TournamentPage(){ - return (

Сторінка для інфомації про конкретний тур

) -} +import { useState, useRef, useEffect, type ReactNode } from "react"; +import { Header } from "../../components/Header"; +import { Hero } from "../../components/Hero"; -export default TournamentPage; \ No newline at end of file +const TABS = [ + { id: "desc", label: "Опис завдання" }, + { id: "looking", label: "Шукають команду" }, + { id: "teams", label: "Команди (3)" }, + { id: "results", label: "Результати" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; +type TourneyStatus = "registration" | "active" | "waiting" | "finished"; + +const RegistrationIcon = () => ( + + + + + + +); + +const ActiveIcon = () => ( + + + +); + +const WaitingIcon = () => ( + + + + +); + +const FinishedIcon = () => ( + + + + +); + +const GameDevIcon = () => ( + + + + +); + +const ClockIcon = () => ( + + + + +); + +const CheckCircleIcon = () => ( + + + + +); + +const STATUS_CONFIG: Record< + TourneyStatus, + { label: string; className: string; icon: ReactNode } +> = { + registration: { + label: "Реєстрація", + className: "bg-blue-400 text-blue-950 shadow-blue-400/20", + icon: , + }, + active: { + label: "Активно", + className: "bg-emerald-400 text-emerald-950 shadow-emerald-400/20", + icon: , + }, + waiting: { + label: "Очікування результатів", + className: "bg-accent text-dark-theme shadow-accent/20", + icon: , + }, + finished: { + label: "Завершено", + className: "bg-white/20 text-white backdrop-blur-md border border-white/20", + icon: , + }, +}; + +export const TournamentPage = () => { + const currentStatus: TourneyStatus = "active"; + const statusInfo = STATUS_CONFIG[currentStatus]; + + return ( +
+
+ + +
+
+ {statusInfo.icon} {statusInfo.label} +
+
+ GameDev & Алгоритми +
+
+ + + Slovo Game Jam + + +
+ +
+ +
+ +
+ +
+ + +
+
+ } + /> + + +
+ ); +}; + +const StatItem = ({ value, label }: { value: string; label: string }) => ( +
+ + {value} + + + {label} + +
+); + +const TournamentMainContent = () => { + const [activeTab, setActiveTab] = useState("desc"); + const [lineStyle, setLineStyle] = useState({ left: 0, width: 0 }); + const tabsRef = useRef<(HTMLButtonElement | null)[]>([]); + + useEffect(() => { + const activeIndex = TABS.findIndex((tab) => tab.id === activeTab); + const activeElement = tabsRef.current[activeIndex]; + + if (activeElement) { + setLineStyle({ + left: activeElement.offsetLeft, + width: activeElement.offsetWidth, + }); + } + }, [activeTab]); + + return ( +
+
+
+ {TABS.map((tab, index) => ( + + ))} +
+
+
+ +
+ {activeTab === "desc" && } + {activeTab !== "desc" && } +
+
+ ); +}; + +const DescriptionTab = () => ( +
+
+

+ Що потрібно зробити? +

+

+ Ваша мета — створити ядро аналог лінукс, створити ядро аналог лінукс, + створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро + аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, + створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро + аналог лінукс, створити ядро аналог лінукс +

+
+ +
+

+ Ключові вимоги: +

+
    +
  • +
    +

    + створити ядро аналог лінукс, створити ядро аналог лінукс, створити + ядро аналог лінукс, +

    +
  • +
  • +
    +

    створити ядро аналог лінукс,

    +
  • +
  • +
    +

    створити ядро аналог лінукс, створити ядро аналог лінукс,

    +
  • +
+
+ +
+

+ Стек технологій: +

+

+ Жодних жорстких обмежень! Всього лиш вимога писати на перфокатрі, та + використовуючи два резистора і пачку мівіни змусити це чудо запуститись. +

+ +
+ {["Перфокарти", "Два резистора", "Пачка мівіни"].map((tech) => ( + + {tech} + + ))} +
+
+
+); + +const PlaceholderTab = () => ( +
+
+ +
+

+ Скоро буде... +

+

+ Інформація для цього розділу наразі готується. Повертайтеся трохи згодом! +

+
+); diff --git a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx index deef3e3..36a3e59 100644 --- a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx +++ b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx @@ -1,5 +1,3 @@ -function TournamentsPage(){ - return (

Сторінка для всіх турнірів

) -} - -export default TournamentsPage; \ No newline at end of file +export const TournamentsPage = () => { + return

Сторінка для всіх турнірів

; +}; From cc899421a2220a3f644d57c1d843b606cdbfb125 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 9 Mar 2026 10:24:28 +0200 Subject: [PATCH 036/369] feat: implement role request dis/approval --- backend/app/config.py | 9 +++++ backend/app/routes/role_request.py | 61 +++++++++++++++++++++++++++++ backend/app/schemas/__init__.py | 2 + backend/app/schemas/notification.py | 7 +++- backend/app/schemas/role_request.py | 14 +++++++ backend/app/util.py | 13 ++++++ backend/tests/test_util.py | 10 +++++ 7 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 backend/app/routes/role_request.py create mode 100644 backend/app/schemas/role_request.py create mode 100644 backend/app/util.py create mode 100644 backend/tests/test_util.py diff --git a/backend/app/config.py b/backend/app/config.py index b57c0e3..bd7c660 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,15 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file="../.env") SECRET_KEY: str = os.getenv("SECRET_KEY") SQLALCHEMY_DATABASE_URI: str = os.getenv("SQLALCHEMY_DATABASE_URI") + ROLE_REQUEST_NOTIFICATION_MESSAGE = ''' + A user $user requested a role $role. Do you approve this request? + ''' + ROLE_REQUEST_APPROVED_MESSAGE = ''' + Your role request was approved. The role $role was granted to you! + ''' + ROLE_REQUEST_DIS_MESSAGE = ''' + Your role request was approved. You were not granted the role $role + ''' settings = Settings() diff --git a/backend/app/routes/role_request.py b/backend/app/routes/role_request.py new file mode 100644 index 0000000..d43d0f6 --- /dev/null +++ b/backend/app/routes/role_request.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, status, HTTPException, Depends +from typing import Annotated +from app.dependencies import SessionDep, CurrentUserDep +from sqlalchemy import select +from app.models import RoleRequest, User, Role +from app.schemas import RoleRequestPublic, RoleRequestCreate, UserPublic + +router = APIRouter(prefix='/role-requests', tags=['role requests']) + +async def get_role_request(request_id: int, session: SessionDep) -> RoleRequest: + statement = select(RoleRequest).where(RoleRequest.id==request_id) + request = await session.execute(statement) + if not request.first(): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Role request not found!') + return request + +async def get_admin_user(current_user: CurrentUserDep, session: SessionDep) -> User: + statement = select(User).where(User.id==current_user.id).filter(User.roles.contains(Role.name == 'admin')) + user = await session.execute(statement) + if not user: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail='Permission denied!') + return user + + +RoleRequestDep = Annotated[RoleRequest, Depends(get_role_request)] + +AdminUserDep = Annotated[User, Depends(get_admin_user)] + +@router.post('/', + response_model=RoleRequestPublic, + dependencies=[CurrentUserDep]) +async def create_request( + session: SessionDep, + request: RoleRequestCreate +): + request = RoleRequestCreate.model_validate(request) + session.add(request) + await session.commit() + return request + +# TODO: implement role request dis/approval permission handling +@router.post('/{request_id}/approve/', response_model=UserPublic) +async def approve_request(request_id: int, request: RoleRequestDep, session: SessionDep): + request.user.roles.add(request.role) + await session.refresh(request.user) + await session.delete(request) + await session.commit() + return request.user + +@router.post('/{request_id}/disapprove/', response_model=UserPublic) +async def approve_request(request_id: int, request: RoleRequestDep, session: SessionDep): + await session.delete(request) + await session.commit() + return request.user + +@router.delete('/{request_id}/', + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[CurrentUserDep]) +async def delete_request(request_id: int, request: RoleRequestDep, session: SessionDep, current_user: CurrentUserDep): + await session.delete(request) + await session.commit() \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d6fbfa0..fe29831 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -3,3 +3,5 @@ from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel from .user import UserModel +from .role_request import RoleRequestPublic, RoleRequestCreate +from .notification import NotificationPublic, NotificationCreate diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 5e725ee..bd1a38f 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -1,10 +1,15 @@ from pydantic import BaseModel, Field, field_validator -class NotificationModel(BaseModel): +class NotificationBase(BaseModel): body: str = Field(..., description="Notification body") + user_id: int = Field(..., description="Notification receiver") user: str +class NotificationPublic(NotificationBase): + pass + +class NotificationCreate(NotificationBase): @field_validator("body") @classmethod def check_body(cls, value: str): diff --git a/backend/app/schemas/role_request.py b/backend/app/schemas/role_request.py new file mode 100644 index 0000000..82014b2 --- /dev/null +++ b/backend/app/schemas/role_request.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, Field, field_validator + + +class RoleRequestBase(BaseModel): + role_id: int = Field(..., description="Role being requested") + user_id: int = Field(..., description="User requesting the role") + role: str + user: str + +class RoleRequestPublic(RoleRequestBase): + pass + +class RoleRequestCreate(RoleRequestBase): + pass \ No newline at end of file diff --git a/backend/app/util.py b/backend/app/util.py new file mode 100644 index 0000000..4e11c77 --- /dev/null +++ b/backend/app/util.py @@ -0,0 +1,13 @@ +from app.dependencies import SessionDep +from app.schemas import NotificationCreate, NotificationPublic +from string import Template + +async def send_notification(notification: NotificationCreate, session: SessionDep, **kwargs) -> NotificationPublic: + notification = NotificationCreate.model_validate(notification) + try: + notification.body = Template(notification.body).substitute(**kwargs) + except ValueError: + raise ValueError('Notification body placeholder was not provided!') + session.add(notification) + await session.commit() + return NotificationPublic.model_validate(notification) \ No newline at end of file diff --git a/backend/tests/test_util.py b/backend/tests/test_util.py new file mode 100644 index 0000000..92b9d32 --- /dev/null +++ b/backend/tests/test_util.py @@ -0,0 +1,10 @@ +import pytest +from app.util import send_notification +from app.schemas import NotificationCreate +from app.config import settings + +@pytest.mark.asyncio +async def test_send_role_request_notification(db_session, user, role): + + notification = NotificationCreate(body=settings.ROLE_REQUEST_NOTIFICATION_MESSAGE, user=user.full_name, role=role.name) + await send_notification(notification, db_session) \ No newline at end of file From 609d7de37be0d61c0b15155887fc25cdcbaab820 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 2 Mar 2026 22:00:43 +0200 Subject: [PATCH 037/369] feat(routes): impelent edit profile and delete profile --- backend/app/dependencies/__init__.py | 2 ++ backend/app/dependencies/current_user.py | 8 ++++++++ backend/app/dependencies/session.py | 6 ++++++ backend/app/main.py | 7 ++++++- backend/app/routes/profile.py | 18 ++++++++++++++++++ backend/app/routes/tournaments.py | 21 +++++++++++++++++++++ backend/app/routes/users.py | 21 +++++++++++++++++++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/user.py | 17 ++++++++++++----- backend/conftest.py | 9 +++++++++ backend/tests/routes/__init__.py | 0 backend/tests/routes/test_profile.py | 22 ++++++++++++++++++++++ 12 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 backend/app/dependencies/__init__.py create mode 100644 backend/app/dependencies/current_user.py create mode 100644 backend/app/dependencies/session.py create mode 100644 backend/app/routes/profile.py create mode 100644 backend/app/routes/tournaments.py create mode 100644 backend/app/routes/users.py create mode 100644 backend/tests/routes/__init__.py create mode 100644 backend/tests/routes/test_profile.py diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py new file mode 100644 index 0000000..4eb3da3 --- /dev/null +++ b/backend/app/dependencies/__init__.py @@ -0,0 +1,2 @@ +from .current_user import CurrentUserDep, get_current_user +from .session import SessionDep, get_session \ No newline at end of file diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py new file mode 100644 index 0000000..5ac6278 --- /dev/null +++ b/backend/app/dependencies/current_user.py @@ -0,0 +1,8 @@ +from typing import Annotated +from fastapi import Depends +from app.models import User + +async def get_current_user(): + raise NotImplementedError('Authentication logic has to be implemented first!') + +CurrentUserDep = Annotated[User, Depends(get_current_user)] \ No newline at end of file diff --git a/backend/app/dependencies/session.py b/backend/app/dependencies/session.py new file mode 100644 index 0000000..6250186 --- /dev/null +++ b/backend/app/dependencies/session.py @@ -0,0 +1,6 @@ +from app.db import get_session +from typing import Annotated +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +SessionDep = Annotated[AsyncSession, Depends(get_session)] \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 95f3f53..547ab03 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,9 @@ from fastapi import FastAPI - +import app.routes.tournaments as tournaments +import app.routes.users as users +import app.routes.profile as profile app = FastAPI() +app.include_router(tournaments.router) +app.include_router(users.router) +app.include_router(profile.router) diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py new file mode 100644 index 0000000..0f6af75 --- /dev/null +++ b/backend/app/routes/profile.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, status +from app.schemas import UserUpdate, UserPublic +from app.dependencies import SessionDep, CurrentUserDep + +router = APIRouter(prefix='/profile', tags=['profile']) + +@router.patch('/', response_model=UserPublic) +async def edit_profile(session: SessionDep, current_user: CurrentUserDep, + user: UserUpdate): + user_data = user.model_dump(exclude_unset=True) + current_user.full_name = user_data['full_name'] + await session.commit() + return current_user + +@router.delete('/', status_code=status.HTTP_204_NO_CONTENT) +async def delete_profile(session: SessionDep, current_user: CurrentUserDep): + await session.delete(current_user) + await session.commit() \ No newline at end of file diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py new file mode 100644 index 0000000..16913f2 --- /dev/null +++ b/backend/app/routes/tournaments.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas import TournamentModels +from app.models import Tournament +from app.dependencies import SessionDep +from sqlalchemy import select + +router = APIRouter(prefix='/tournaments', tags=['tournaments']) + +@router.get('/', response_model=list[TournamentModels]) +async def tournaments(session: SessionDep): + statement = select(Tournament) + tournaments = await session.execute(statement) + return tournaments.all() + +@router.get('/{tournament_id}/', response_model=TournamentModels) +async def tournament(tournament_id: int, session: SessionDep): + statement = select(Tournament).where(Tournament.id==tournament_id) + tournament = await session.execute(statement) + if not tournament.first(): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') + return tournament.first() \ No newline at end of file diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py new file mode 100644 index 0000000..769776a --- /dev/null +++ b/backend/app/routes/users.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas import UserModel +from app.models import User +from app.dependencies import SessionDep +from sqlalchemy import select + +router = APIRouter(prefix='/users', tags=['users']) + +@router.get('/', response_model=list[UserModel]) +async def users(session: SessionDep): + statement = select(User) + users = await session.execute(statement) + return users.all() + +@router.get('/{user_id}/', response_model=UserModel) +async def user(user_id: int, session: SessionDep): + statement = select(User).where(User.id==user_id) + user = await session.execute(statement) + if not user.first(): + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') + return user.first() \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index fe29831..40c5e62 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -5,3 +5,4 @@ from .user import UserModel from .role_request import RoleRequestPublic, RoleRequestCreate from .notification import NotificationPublic, NotificationCreate +from .user import UserModel, UserPublic, UserUpdate diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a179c76..dcee402 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,11 +1,7 @@ from pydantic import BaseModel, Field, EmailStr, field_validator - -class UserModel(BaseModel): +class UserBase(BaseModel): full_name: str = Field(..., description="Username") - email: EmailStr = Field(..., description="User email") - password: str = Field(..., min_length=6, description="User password") - role: list[str] @field_validator("full_name") @classmethod @@ -14,6 +10,17 @@ def check_name(cls, value: str): raise ValueError("The name cannot be empty") return value +class UserUpdate(UserBase): + full_name: str | None = None + +class UserPublic(UserBase): + email: EmailStr + +class UserModel(UserBase): + email: EmailStr = Field(..., description="User email") + password: str = Field(..., min_length=6, description="User password") + role: list[str] + @field_validator("email") @classmethod def check_email(cls, value: str): diff --git a/backend/conftest.py b/backend/conftest.py index 3cf96ca..28bcf14 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,6 +1,9 @@ import pytest from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from app.main import app +from fastapi.testclient import TestClient +from app.dependencies import get_session from app.models import ( Base, @@ -34,8 +37,14 @@ async def setup_database(): @pytest.fixture async def db_session(): async with AsyncTestingSessionLocal() as session: + app.dependency_overrides[get_session] = lambda: session yield session + app.dependency_overrides.pop(get_session) +@pytest.fixture(scope='session') +async def client(): + client = TestClient(app) + yield client @pytest.fixture async def user(db_session): diff --git a/backend/tests/routes/__init__.py b/backend/tests/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/routes/test_profile.py b/backend/tests/routes/test_profile.py new file mode 100644 index 0000000..eee04fd --- /dev/null +++ b/backend/tests/routes/test_profile.py @@ -0,0 +1,22 @@ +import pytest +from app.main import app +from app.dependencies import get_current_user +from app.models import User + +async def test_update_profile(user, client): + full_name = 'John Doe' + app.dependency_overrides[get_current_user] = lambda: user + assert user.full_name != full_name + resp = client.patch('/profile/', json={ + 'full_name': full_name + }) + assert resp.json()['full_name'] == full_name + app.dependency_overrides.pop(get_current_user) + +async def test_delete_profile(user, client, db_session): + assert await db_session.get(User, user.id) is not None + app.dependency_overrides[get_current_user] = lambda: user + resp = client.delete('/profile/') + assert resp.status_code == 204 + assert await db_session.get(User, user.id) is None + app.dependency_overrides.pop(get_current_user) \ No newline at end of file From 9c9dc2ba5a9e63ceaa6095d11d0979a0d470d0c5 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 9 Mar 2026 10:39:59 +0200 Subject: [PATCH 038/369] fix: calling send_notification causes pydantic error --- backend/app/config.py | 6 +++--- backend/app/models/user.py | 2 +- backend/app/schemas/notification.py | 3 +-- backend/app/schemas/role_request.py | 5 ++--- backend/app/util.py | 10 ++++++---- backend/tests/test_util.py | 5 ++--- 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index bd7c660..c591d6e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,13 +6,13 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file="../.env") SECRET_KEY: str = os.getenv("SECRET_KEY") SQLALCHEMY_DATABASE_URI: str = os.getenv("SQLALCHEMY_DATABASE_URI") - ROLE_REQUEST_NOTIFICATION_MESSAGE = ''' + ROLE_REQUEST_NOTIFICATION_MESSAGE: str = ''' A user $user requested a role $role. Do you approve this request? ''' - ROLE_REQUEST_APPROVED_MESSAGE = ''' + ROLE_REQUEST_APPROVED_MESSAGE: str = ''' Your role request was approved. The role $role was granted to you! ''' - ROLE_REQUEST_DIS_MESSAGE = ''' + ROLE_REQUEST_DIS_MESSAGE: str = ''' Your role request was approved. You were not granted the role $role ''' diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0fe187b..5310f3f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -27,7 +27,7 @@ class User(Base, PKMixin): notifications: Mapped[list["Notification"]] = relationship( back_populates="user" ) - requests: Mapped[list["RoleRequest"]] = relationship( + role_requests: Mapped[list["RoleRequest"]] = relationship( back_populates="user" ) created_tournaments: Mapped[list["Tournament"]] = relationship( diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index bd1a38f..a4056f2 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -4,10 +4,9 @@ class NotificationBase(BaseModel): body: str = Field(..., description="Notification body") user_id: int = Field(..., description="Notification receiver") - user: str class NotificationPublic(NotificationBase): - pass + user: str class NotificationCreate(NotificationBase): @field_validator("body") diff --git a/backend/app/schemas/role_request.py b/backend/app/schemas/role_request.py index 82014b2..20653c4 100644 --- a/backend/app/schemas/role_request.py +++ b/backend/app/schemas/role_request.py @@ -4,11 +4,10 @@ class RoleRequestBase(BaseModel): role_id: int = Field(..., description="Role being requested") user_id: int = Field(..., description="User requesting the role") - role: str - user: str class RoleRequestPublic(RoleRequestBase): - pass + role: str + user: str class RoleRequestCreate(RoleRequestBase): pass \ No newline at end of file diff --git a/backend/app/util.py b/backend/app/util.py index 4e11c77..6314b61 100644 --- a/backend/app/util.py +++ b/backend/app/util.py @@ -1,13 +1,15 @@ from app.dependencies import SessionDep -from app.schemas import NotificationCreate, NotificationPublic +from app.schemas import NotificationCreate +from app.models import Notification from string import Template -async def send_notification(notification: NotificationCreate, session: SessionDep, **kwargs) -> NotificationPublic: +async def send_notification(notification: NotificationCreate, session: SessionDep, **kwargs) -> Notification: notification = NotificationCreate.model_validate(notification) try: - notification.body = Template(notification.body).substitute(**kwargs) + notification.body = str(Template(notification.body).substitute(**kwargs)) except ValueError: raise ValueError('Notification body placeholder was not provided!') + notification = Notification(**notification.model_dump()) session.add(notification) await session.commit() - return NotificationPublic.model_validate(notification) \ No newline at end of file + return notification \ No newline at end of file diff --git a/backend/tests/test_util.py b/backend/tests/test_util.py index 92b9d32..05a26e7 100644 --- a/backend/tests/test_util.py +++ b/backend/tests/test_util.py @@ -5,6 +5,5 @@ @pytest.mark.asyncio async def test_send_role_request_notification(db_session, user, role): - - notification = NotificationCreate(body=settings.ROLE_REQUEST_NOTIFICATION_MESSAGE, user=user.full_name, role=role.name) - await send_notification(notification, db_session) \ No newline at end of file + notification = NotificationCreate(body=settings.ROLE_REQUEST_NOTIFICATION_MESSAGE, user_id=user.id) + await send_notification(notification, db_session, user=user.full_name, role=role.name) \ No newline at end of file From 85d3c8d2148742936d3aa3e25198a586ca5f6833 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Mon, 9 Mar 2026 20:21:05 +0000 Subject: [PATCH 039/369] wrote tests for tournament models --- backend/tests/test_models.py | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index b6503fa..8eaac43 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -8,6 +8,8 @@ TeamMember, Team, Task, + Tournament, + TournamentStatusOption, TaskRequirementCategory, TaskRequirementOption, ) @@ -26,6 +28,7 @@ ) +# USER TESTS async def test_create_user(create): user = await create(UserFactory) assert user.id is not None @@ -51,6 +54,7 @@ async def test_create_user_without_password(create): await create(UserFactory, password=None) +# ROLE TESTS async def test_create_role(create): role = await create(RoleFactory, name="jury") assert role.id is not None @@ -86,6 +90,7 @@ async def test_user_roles_relationship(db_session, create): assert "admin" in [r.name for r in db_user.roles] +# TEAM/TEAM MEMBER TESTS async def test_create_team_member(create): member = await create(TeamMemberFactory) @@ -178,6 +183,7 @@ async def test_team_cascade_delete_members(db_session, create): assert result.scalar_one_or_none() is None +# TASK TESTS async def test_create_task(create): task = await create(TaskFactory) @@ -237,3 +243,114 @@ async def test_task_category_hierarchy(db_session, create): assert len(db_parent.sub_categories) == 1 assert db_parent.sub_categories[0].name == child.name assert db_parent.sub_categories[0].parent_category.name == parent.name + + +# TOURNAMENT TESTS +async def test_create_tournament(create): + tournament = await create(TournamentFactory) + + assert tournament.title is not None + assert tournament.description is not None + assert tournament.max_team is not None + + +async def test_tournament_without_data(db_session): + tournament = Tournament() + db_session.add(tournament) + with pytest.raises(IntegrityError): + await db_session.flush() + + await db_session.rollback() + + +async def test_tournament_invalid_time(create): + tournament = await create( + TournamentFactory, + reg_start=datetime.now() + timedelta(days=1), + reg_end=datetime.now() - timedelta(hours=1), + ) + + assert tournament.reg_end < tournament.reg_start + + +async def test_tournament_without_creator(db_session, create): + with pytest.raises(IntegrityError): + await create(TournamentFactory, creator=None) + + await db_session.rollback() + + +async def test_tournament_creator_relationship(db_session, create): + tournament = await create(TournamentFactory) + + stmt = ( + select(Tournament) + .where(Tournament.id == tournament.id) + .options(selectinload(Tournament.creator)) + ) + + result = await db_session.execute(stmt) + db_tournament = result.scalar_one() + + assert db_tournament.creator is not None + assert db_tournament.creator.id == tournament.creator.id + + +async def test_tournament_status_relationship(db_session, create): + tournament = await create(TournamentFactory) + + stmt = ( + select(Tournament) + .where(Tournament.id == tournament.id) + .options(selectinload(Tournament.status)) + ) + + result = await db_session.execute(stmt) + db_tournament = result.scalar_one() + + assert db_tournament.status is not None + assert db_tournament.status.name in [ + "Registration Open", + "Ongoing", + "Finished", + ] + + +async def test_tournament_tasks_relationship(db_session, create): + tournament = await create(TournamentFactory) + + await create(TaskFactory, tournament=tournament) + await create(TaskFactory, tournament=tournament) + + stmt = ( + select(Tournament) + .where(Tournament.id == tournament.id) + .options(selectinload(Tournament.tasks)) + ) + + result = await db_session.execute(stmt) + db_tournament = result.scalar_one() + + assert len(db_tournament.tasks) == 2 + + +async def test_create_tournament_status_option(db_session, create): + tournament_status_option = await create(TournamentStatusOptionFactory) + + stmt = select(TournamentStatusOption).where( + TournamentStatusOption.id == tournament_status_option.id + ) + + result = await db_session.execute(stmt) + db_tournament_status_option = result.scalar_one() + + assert db_tournament_status_option.name == tournament_status_option.name + + +async def test_tournament_status_option_unique_name(db_session, create): + await create(TournamentStatusOptionFactory, name="Ongoing") + + with pytest.raises(IntegrityError): + await create(TournamentStatusOptionFactory, name="Ongoing") + + await db_session.rollback() From 6e8bd579bf7cfe2f2fcc91325a6dc99af0a32442 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Mon, 9 Mar 2026 22:17:51 +0000 Subject: [PATCH 040/369] update models from dev --- backend/app/models/notification.py | 20 ++++++++++++++++++++ backend/app/models/role_request.py | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/role_request.py diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..701e35b --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey + +from .base import Base +from .mixin import PKMixin +from .user import User + + +class Notification(Base, PKMixin): + __tablename__ = "notifications" + + body: Mapped[str] = mapped_column(String(4096), unique=True) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + + user: Mapped["User"] = relationship( + back_populates="notifications" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py new file mode 100644 index 0000000..f04663f --- /dev/null +++ b/backend/app/models/role_request.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey + +from .base import Base +from .mixin import PKMixin +from .user import User +from .role import Role + + +class RoleRequest(Base, PKMixin): + __tablename__ = "role_requests" + + role_id: Mapped[int] = mapped_column(ForeignKey('roles.id')) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + + role: Mapped["Role"] = relationship( + back_populates="requests" + ) + user: Mapped["User"] = relationship( + back_populates="role_requests" + ) + + def __repr__(self): + return f"" From fff3da829288f11f9ba3258674dbe3d6c1c23e3e Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 10 Mar 2026 19:07:40 +0000 Subject: [PATCH 041/369] Temporarily commented out the notification.py and role_request.py models, fixed the models (Submission, SubmissionEvaluation ...), added factories, and wrote several tests for SUBMISSION. --- backend/app/models/__init__.py | 6 ++++-- backend/app/models/evaluation.py | 12 ++++++++---- backend/app/models/notification.py | 25 +++++++++++------------- backend/app/models/role.py | 1 + backend/app/models/role_request.py | 31 +++++++++++++----------------- backend/app/models/submission.py | 12 ++++++++++-- backend/app/models/team.py | 4 ++++ backend/app/models/user.py | 2 ++ backend/tests/factories.py | 26 +++++++++++++++++++++++++ backend/tests/test_models.py | 31 ++++++++++++++++++++++++++++++ 10 files changed, 110 insertions(+), 40 deletions(-) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1becc32..1b29b3e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,8 @@ from .base import Base +from .evaluation import SubmissionEvaluation, RequirementEvaluation from .role import Role +from .submission import Submission, SubmissionUrl, SubmissionUrlOption +from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption from .team import Team, TeamMember -from .user import User from .tournament import Tournament, TournamentStatusOption -from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption +from .user import User diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index ac3a805..a1543be 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -1,6 +1,8 @@ from sqlalchemy import ForeignKey, Table, Column from sqlalchemy.orm import mapped_column, Mapped, relationship + from .base import Base +from .mixin import PKMixin evaluation_requirements = Table( "evaluation_requirements", @@ -18,7 +20,7 @@ ) -class SubmissionEvaluation(Base): +class SubmissionEvaluation(Base, PKMixin): __tablename__ = "evaluations" submission_id: Mapped[int] = mapped_column( ForeignKey("submissions.team_id"), primary_key=True @@ -27,12 +29,12 @@ class SubmissionEvaluation(Base): submission: Mapped["Submission"] = relationship(back_populates="evaluations") jury: Mapped["User"] = relationship() - evaluations: Mapped[list["RequirementEvaluation"]] = relationship( + requirement_evaluations: Mapped[list["RequirementEvaluation"]] = relationship( back_populates="evaluation" ) -class RequirementEvaluation(Base): +class RequirementEvaluation(Base, PKMixin): __tablename__ = "requirement_evaluations" evaluation_id: Mapped[int] = mapped_column( ForeignKey("evaluations.id"), primary_key=True @@ -41,4 +43,6 @@ class RequirementEvaluation(Base): back_populates="requirement_evaluations" ) score: Mapped[int] - requirement: Mapped["TaskRequirementOption"] = relationship() + requirement: Mapped[list["TaskRequirementOption"]] = relationship( + secondary=evaluation_requirements + ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 701e35b..e713f25 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -1,20 +1,17 @@ -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import String, ForeignKey +# from sqlalchemy.orm import Mapped, mapped_column, relationship +# from sqlalchemy import String, ForeignKey -from .base import Base -from .mixin import PKMixin -from .user import User +# from .base import Base +# from .mixin import PKMixin -class Notification(Base, PKMixin): - __tablename__ = "notifications" +# class Notification(Base, PKMixin): +# __tablename__ = "notifications" - body: Mapped[str] = mapped_column(String(4096), unique=True) - user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) +# body: Mapped[str] = mapped_column(String(4096), unique=True) +# user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) - user: Mapped["User"] = relationship( - back_populates="notifications" - ) +# user: Mapped["User"] = relationship("User", back_populates="notifications") - def __repr__(self): - return f"" +# def __repr__(self): +# return f"" diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 27b977d..79c07b4 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -13,6 +13,7 @@ class Role(Base, PKMixin): users: Mapped[list["User"]] = relationship( secondary=user_roles, back_populates="roles" ) + # requests: Mapped[list["RoleRequest"]] = relationship(back_populates="role") def __repr__(self): return f"" diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py index f04663f..8795e81 100644 --- a/backend/app/models/role_request.py +++ b/backend/app/models/role_request.py @@ -1,24 +1,19 @@ -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import ForeignKey +# from sqlalchemy.orm import Mapped, mapped_column, relationship +# from sqlalchemy import ForeignKey -from .base import Base -from .mixin import PKMixin -from .user import User -from .role import Role +# from .base import Base +# from .mixin import PKMixin -class RoleRequest(Base, PKMixin): - __tablename__ = "role_requests" - role_id: Mapped[int] = mapped_column(ForeignKey('roles.id')) - user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) +# class RoleRequest(Base, PKMixin): +# __tablename__ = "role_requests" - role: Mapped["Role"] = relationship( - back_populates="requests" - ) - user: Mapped["User"] = relationship( - back_populates="role_requests" - ) +# role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) +# user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) - def __repr__(self): - return f"" +# role: Mapped["Role"] = relationship(back_populates="requests") +# user: Mapped["User"] = relationship(back_populates="role_requests") + +# def __repr__(self): +# return f"" diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 4380687..f6fc3d8 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -7,14 +7,22 @@ class Submission(Base): __tablename__ = "submissions" team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True) + team: Mapped["Team"] = relationship(back_populates="submission", single_parent=True) urls: Mapped[list["SubmissionUrl"]] = relationship(back_populates="submission") + evaluations: Mapped[list["SubmissionEvaluation"]] = relationship( + back_populates="submission" + ) class SubmissionUrl(Base): __tablename__ = "submission_urls" - submission_id: Mapped[int] = mapped_column(ForeignKey("submissions.team_id")) - url_id: Mapped[int] = mapped_column(ForeignKey("submission_url_options.name")) + submission_id: Mapped[int] = mapped_column( + ForeignKey("submissions.team_id"), primary_key=True + ) + url_id: Mapped[int] = mapped_column( + ForeignKey("submission_url_options.name"), primary_key=True + ) submission: Mapped["Submission"] = relationship(back_populates="urls") url: Mapped["SubmissionUrlOption"] = relationship() diff --git a/backend/app/models/team.py b/backend/app/models/team.py index b26d9db..8e52c89 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -26,6 +26,10 @@ class Team(Base, PKMixin): "TeamMember", foreign_keys="Team.captain_id", post_update=True ) + submission: Mapped["Submission"] = relationship( + back_populates="team", cascade="all, delete-orphan" + ) + def __repr__(self): return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1f7d7f1..13d7b99 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -24,6 +24,8 @@ class User(Base, PKMixin): roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users" ) + # notifications: Mapped[list["Notification"]] = relationship(back_populates="user") + # role_requests: Mapped[list["RoleRequest"]] = relationship(back_populates="user") created_tournaments: Mapped[list["Tournament"]] = relationship( back_populates="creator" ) diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 6899fe9..2a190f6 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -13,6 +13,9 @@ TaskStatusOption, TaskRequirementOption, TaskRequirementCategory, + Submission, + SubmissionUrl, + SubmissionUrlOption, ) @@ -127,3 +130,26 @@ class Meta: display_name = factory.LazyAttribute(lambda f: f.name.upper()) category = factory.SubFactory(TaskRequirementCategoryFactory) + + +class SubmissionFactory(BaseFactory): + class Meta: + model = Submission + + team = factory.SubFactory(TeamFactory) + + +class SubmissionUrlOptionFactory(BaseFactory): + class Meta: + model = SubmissionUrlOption + + name = factory.Sequence(lambda n: f"url_option_{n}") + display_name = factory.LazyAttribute(lambda f: f.name.upper()) + + +class SubmissionUrlFactory(BaseFactory): + class Meta: + model = SubmissionUrl + + submission = factory.SubFactory(SubmissionFactory) + url = factory.SubFactory(SubmissionUrlOptionFactory) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 8eaac43..df2416d 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -12,6 +12,9 @@ TournamentStatusOption, TaskRequirementCategory, TaskRequirementOption, + Submission, + SubmissionUrl, + SubmissionUrlOption, ) from .factories import ( @@ -25,6 +28,9 @@ TaskFactory, TaskRequirementCategoryFactory, TaskRequirementOptionFactory, + SubmissionFactory, + SubmissionUrlFactory, + SubmissionUrlOptionFactory, ) @@ -354,3 +360,28 @@ async def test_tournament_status_option_unique_name(db_session, create): await create(TournamentStatusOptionFactory, name="Ongoing") await db_session.rollback() + + +# SUBMISSION TESTS +async def test_create_submission(create): + submission = await create(SubmissionFactory) + + assert submission.team_id is not None + assert submission.team is not None + + +async def test_submission_urls_relationship(db_session, create): + submission = await create(SubmissionFactory) + await create(SubmissionUrlFactory, submission=submission) + await create(SubmissionUrlFactory, submission=submission) + + stmt = ( + select(Submission) + .where(Submission.team_id == submission.team_id) + .options(selectinload(Submission.urls)) + ) + + result = await db_session.execute(stmt) + db_submission = result.scalar_one() + + assert len(db_submission.urls) == 2 From e4a68c9e336342479432be631af1697079c6d3b8 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 10 Mar 2026 22:45:20 +0000 Subject: [PATCH 042/369] Wrote tests for Submission model, add 2 tests for TaskStatusOption + small change in UserFactory --- backend/tests/factories.py | 2 +- backend/tests/test_models.py | 66 ++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 2a190f6..b3b4628 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -31,7 +31,7 @@ class Meta: model = User full_name = Faker("name") - email = "test@example.com" + email = factory.Sequence(lambda n: f"user{n}@example.com") password = "very_strong_password" diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index df2416d..8547972 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -11,7 +11,6 @@ Tournament, TournamentStatusOption, TaskRequirementCategory, - TaskRequirementOption, Submission, SubmissionUrl, SubmissionUrlOption, @@ -143,10 +142,11 @@ async def test_team_without_data(db_session): async def test_team_duplicate_email(db_session, create): - await create(TeamFactory) + email = "duplicate@example.com" + await create(TeamFactory, team_email=email) with pytest.raises(IntegrityError): - await create(TeamFactory) + await create(TeamFactory, team_email=email) await db_session.rollback() @@ -251,6 +251,25 @@ async def test_task_category_hierarchy(db_session, create): assert db_parent.sub_categories[0].parent_category.name == parent.name +async def test_create_task_status_option(create): + status = await create(TaskStatusOptionFactory) + + assert status.name in ["draft", "active", "finished"] + assert status.display_name == status.name.upper() + + +async def test_task_status_relationship(db_session, create): + status = await create(TaskStatusOptionFactory) + task = await create(TaskFactory, status=status) + + stmt = select(Task).where(Task.id == task.id).options(selectinload(Task.status)) + + result = await db_session.execute(stmt) + db_task = result.scalar_one() + + assert db_task.status.name == status.name + + # TOURNAMENT TESTS async def test_create_tournament(create): tournament = await create(TournamentFactory) @@ -385,3 +404,44 @@ async def test_submission_urls_relationship(db_session, create): db_submission = result.scalar_one() assert len(db_submission.urls) == 2 + + +async def test_multiple_submissions(create): + submissions1 = await create(SubmissionFactory) + submissions2 = await create(SubmissionFactory) + + assert submissions1.team_id is not None + assert submissions2.team_id is not None + assert submissions1.team_id != submissions2.team_id + + +async def test_submission_urls_belong_to_submission(create): + submission = await create(SubmissionFactory) + + url1 = await create(SubmissionUrlFactory, submission=submission) + url2 = await create(SubmissionUrlFactory, submission=submission) + + assert url1.submission_id == submission.team_id + assert url2.submission_id == submission.team_id + + +async def test_submission_url_has_submission(db_session, create): + url = await create(SubmissionUrlFactory) + + stmt = ( + select(SubmissionUrl) + .where(SubmissionUrl.submission_id == url.submission_id) + .options(selectinload(SubmissionUrl.submission)) + ) + + result = await db_session.execute(stmt) + db_url = result.scalar_one() + + assert db_url.submission is not None + + +async def test_submission_url_option_values(create): + option = await create(SubmissionUrlOptionFactory) + + assert option.name.startswith("url_option_") + assert option.display_name == option.name.upper() From 548dc1e4c8f586e44cd02812537dbe198d944e51 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 11 Mar 2026 21:31:30 +0200 Subject: [PATCH 043/369] refactor(models): update sqlalchemy model relationship loading & make pydantic model orm-compatible --- backend/app/models/evaluation.py | 10 +++++----- backend/app/models/notification.py | 2 +- backend/app/models/role.py | 4 ++-- backend/app/models/role_request.py | 4 ++-- backend/app/models/submission.py | 8 ++++---- backend/app/models/task.py | 16 ++++++++-------- backend/app/models/team.py | 4 ++-- backend/app/models/tournament.py | 12 ++++++------ backend/app/models/user.py | 10 +++++----- backend/app/schemas/evaluation.py | 6 +++++- backend/app/schemas/notification.py | 4 +++- backend/app/schemas/role.py | 22 ++++++++++++++++++++++ backend/app/schemas/role_request.py | 11 +++++++---- backend/app/schemas/task.py | 4 +++- backend/app/schemas/team.py | 6 +++++- backend/app/schemas/tournament.py | 6 +++++- backend/app/schemas/user.py | 11 +++++++---- 17 files changed, 92 insertions(+), 48 deletions(-) create mode 100644 backend/app/schemas/role.py diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index ac3a805..edaf196 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -25,10 +25,10 @@ class SubmissionEvaluation(Base): ) jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) - submission: Mapped["Submission"] = relationship(back_populates="evaluations") - jury: Mapped["User"] = relationship() + submission: Mapped["Submission"] = relationship(back_populates="evaluations", lazy="selectin") + jury: Mapped["User"] = relationship(lazy="selectin") evaluations: Mapped[list["RequirementEvaluation"]] = relationship( - back_populates="evaluation" + back_populates="evaluation", lazy="selectin" ) @@ -38,7 +38,7 @@ class RequirementEvaluation(Base): ForeignKey("evaluations.id"), primary_key=True ) evaluation: Mapped["SubmissionEvaluation"] = relationship( - back_populates="requirement_evaluations" + back_populates="requirement_evaluations", lazy="selectin" ) score: Mapped[int] - requirement: Mapped["TaskRequirementOption"] = relationship() + requirement: Mapped["TaskRequirementOption"] = relationship(lazy="selectin") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 701e35b..9769d7b 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -13,7 +13,7 @@ class Notification(Base, PKMixin): user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) user: Mapped["User"] = relationship( - back_populates="notifications" + back_populates="notifications", lazy="selectin" ) def __repr__(self): diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 4fb9b95..7781eb2 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -11,10 +11,10 @@ class Role(Base, PKMixin): name: Mapped[str] = mapped_column(nullable=False, unique=True) users: Mapped[list["User"]] = relationship( - secondary=user_roles, back_populates="roles" + secondary=user_roles, back_populates="roles", lazy="selectin" ) requests: Mapped[list["RoleRequest"]] = relationship( - back_populates="role" + back_populates="role", lazy="selectin" ) def __repr__(self): diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py index f04663f..51deb3e 100644 --- a/backend/app/models/role_request.py +++ b/backend/app/models/role_request.py @@ -14,10 +14,10 @@ class RoleRequest(Base, PKMixin): user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) role: Mapped["Role"] = relationship( - back_populates="requests" + back_populates="requests", lazy="selectin" ) user: Mapped["User"] = relationship( - back_populates="role_requests" + back_populates="role_requests", lazy="selectin" ) def __repr__(self): diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 4380687..c9b17ee 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -7,8 +7,8 @@ class Submission(Base): __tablename__ = "submissions" team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True) - team: Mapped["Team"] = relationship(back_populates="submission", single_parent=True) - urls: Mapped[list["SubmissionUrl"]] = relationship(back_populates="submission") + team: Mapped["Team"] = relationship(back_populates="submission", single_parent=True, lazy="selectin") + urls: Mapped[list["SubmissionUrl"]] = relationship(back_populates="submission", lazy="selectin") class SubmissionUrl(Base): @@ -16,8 +16,8 @@ class SubmissionUrl(Base): submission_id: Mapped[int] = mapped_column(ForeignKey("submissions.team_id")) url_id: Mapped[int] = mapped_column(ForeignKey("submission_url_options.name")) - submission: Mapped["Submission"] = relationship(back_populates="urls") - url: Mapped["SubmissionUrlOption"] = relationship() + submission: Mapped["Submission"] = relationship(back_populates="urls", lazy="selectin") + url: Mapped["SubmissionUrlOption"] = relationship(lazy="selectin") class SubmissionUrlOption(Base, OptionMixin): diff --git a/backend/app/models/task.py b/backend/app/models/task.py index c571869..6905bcd 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -26,20 +26,20 @@ class Task(Base, PKMixin): ForeignKey("tournaments.id", use_alter=True, name="fk_task_tournament") ) tournament: Mapped["Tournament"] = relationship( - "Tournament", back_populates="tasks", foreign_keys="Task.tournament_id" + "Tournament", back_populates="tasks", foreign_keys="Task.tournament_id", lazy="selectin" ) start_time: Mapped[datetime] end_time: Mapped[datetime] status_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) - status: Mapped["TaskStatusOption"] = relationship(back_populates="tasks") + status: Mapped["TaskStatusOption"] = relationship(back_populates="tasks", lazy="selectin") requirements: Mapped[list["TaskRequirementOption"]] = relationship( - secondary=task_requirements + secondary=task_requirements, lazy="selectin" ) class TaskStatusOption(Base, OptionMixin): __tablename__ = "task_statuses" - tasks: Mapped[list["Task"]] = relationship(back_populates="status") + tasks: Mapped[list["Task"]] = relationship(back_populates="status", lazy="selectin") class TaskRequirementOption(Base, OptionMixin): @@ -48,7 +48,7 @@ class TaskRequirementOption(Base, OptionMixin): ForeignKey("task_requirement_categories.name") ) category: Mapped["TaskRequirementCategory"] = relationship( - back_populates="task_requirement_options" + back_populates="task_requirement_options", lazy="selectin" ) @@ -56,11 +56,11 @@ class TaskRequirementCategory(Base, OptionMixin): __tablename__ = "task_requirement_categories" main_id: Mapped[str] = mapped_column(ForeignKey("task_requirement_categories.name"), nullable=True) sub_categories: Mapped[list["TaskRequirementCategory"]] = relationship( - back_populates="parent_category" + back_populates="parent_category", lazy="selectin" ) parent_category: Mapped["TaskRequirementCategory"] = relationship( - back_populates="sub_categories", remote_side="TaskRequirementCategory.name" + back_populates="sub_categories", remote_side="TaskRequirementCategory.name", lazy="selectin" ) task_requirement_options: Mapped[list["TaskRequirementOption"]] = relationship( - back_populates="category" + back_populates="category", lazy="selectin" ) diff --git a/backend/app/models/team.py b/backend/app/models/team.py index bf58c42..ba58f1a 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -16,7 +16,7 @@ class Team(Base, PKMixin): ForeignKey("team_members.id", ondelete="CASCADE"), nullable=True ) - tournament: Mapped["Tournament"] = relationship(back_populates="teams") + tournament: Mapped["Tournament"] = relationship(back_populates="teams", lazy="selectin") members: Mapped[list["TeamMember"]] = relationship( back_populates="team", foreign_keys="TeamMember.team_id", @@ -39,7 +39,7 @@ class TeamMember(Base, PKMixin): ) team: Mapped["Team"] = relationship( - back_populates="members", foreign_keys=[team_id] + back_populates="members", foreign_keys=[team_id], lazy="selectin" ) def __repr__(self): diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 92521da..1b14e67 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -20,17 +20,17 @@ class Tournament(Base, PKMixin): status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - teams: Mapped[list["Team"]] = relationship(back_populates="tournament") + teams: Mapped[list["Team"]] = relationship(back_populates="tournament", lazy="selectin") active_task: Mapped["Task"] = relationship( - "Task", foreign_keys="Tournament.active_task_id" + "Task", foreign_keys="Tournament.active_task_id", lazy="selectin" ) tasks: Mapped[list["Task"]] = relationship( - "Task", back_populates="tournament", foreign_keys="Task.tournament_id" + "Task", back_populates="tournament", foreign_keys="Task.tournament_id", lazy="selectin" ) status: Mapped["TournamentStatusOption"] = relationship( - back_populates="tournaments" + back_populates="tournaments", lazy="selectin" ) - creator: Mapped["User"] = relationship(back_populates="created_tournaments") + creator: Mapped["User"] = relationship(back_populates="created_tournaments", lazy="selectin") def __repr__(self): return f"" @@ -41,4 +41,4 @@ class TournamentStatusOption(Base, PKMixin): name: Mapped[str] = mapped_column(unique=True) - tournaments: Mapped[List["Tournament"]] = relationship(back_populates="status") + tournaments: Mapped[List["Tournament"]] = relationship(back_populates="status", lazy="selectin") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5310f3f..0758298 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -22,17 +22,17 @@ class User(Base, PKMixin): created_at: Mapped[datetime] = mapped_column(server_default=func.now()) roles: Mapped[list["Role"]] = relationship( - secondary=user_roles, back_populates="users" + secondary=user_roles, back_populates="users", lazy="selectin" ) notifications: Mapped[list["Notification"]] = relationship( - back_populates="user" + back_populates="user", lazy="selectin" ) role_requests: Mapped[list["RoleRequest"]] = relationship( - back_populates="user" + back_populates="user", lazy="selectin" ) created_tournaments: Mapped[list["Tournament"]] = relationship( - back_populates="creator" + back_populates="creator", lazy="selectin" ) def __repr__(self): - return f"" + return f"" diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py index 6c25c62..69695c9 100644 --- a/backend/app/schemas/evaluation.py +++ b/backend/app/schemas/evaluation.py @@ -1,10 +1,14 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict class RequirementEvaluationModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + score: int = Field(..., ge=0) class SubmissionEvaluationModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + submission_id: int = Field(..., gt=0) jury_id: int = Field(..., gt=0) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index a4056f2..c56fce8 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, ConfigDict class NotificationBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + body: str = Field(..., description="Notification body") user_id: int = Field(..., description="Notification receiver") diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py new file mode 100644 index 0000000..478322b --- /dev/null +++ b/backend/app/schemas/role.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, Field, field_validator, ConfigDict + +class RoleBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str = Field(..., description="Role name") + + +class RoleUpdate(RoleBase): + name: str | None = None + +class RolePublic(RoleBase): + pass + +class RoleCreate(RoleBase): + + @field_validator("name") + @classmethod + def check_name(cls, value: str): + if not value.strip(): + raise ValueError("The name cannot be empty") + return value diff --git a/backend/app/schemas/role_request.py b/backend/app/schemas/role_request.py index 20653c4..efcc6a8 100644 --- a/backend/app/schemas/role_request.py +++ b/backend/app/schemas/role_request.py @@ -1,13 +1,16 @@ -from pydantic import BaseModel, Field, field_validator - +from pydantic import BaseModel, Field, ConfigDict +from .user import UserPublic +from .role import RolePublic class RoleRequestBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + role_id: int = Field(..., description="Role being requested") user_id: int = Field(..., description="User requesting the role") class RoleRequestPublic(RoleRequestBase): - role: str - user: str + user: UserPublic + role: RolePublic class RoleRequestCreate(RoleRequestBase): pass \ No newline at end of file diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 13cd05b..78bca99 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -1,9 +1,11 @@ from datetime import datetime from typing_extensions import Self -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict class TaskModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + title: str = Field(..., min_length=3, description="Short name of the task") description: str = Field( description="A detailed description of what needs to be done" diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 97b5b53..350e5c7 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -1,8 +1,10 @@ -from pydantic import BaseModel, Field, EmailStr, field_validator +from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from pydantic_extra_types.phone_numbers import PhoneNumber class TeamModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + name: str = Field(..., description="Name of the team") team_email: EmailStr = Field(..., description="Contact email") contact_info: PhoneNumber = Field(..., description="Phone number") @@ -16,6 +18,8 @@ def normalize_email(cls, value: EmailStr): class TeamMemberModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + full_name: str = Field(..., min_length=3) email: EmailStr = Field(..., description="Contact email") telegram_username: str diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index b641fae..7984868 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -1,9 +1,11 @@ from datetime import datetime from typing_extensions import Self -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict class TournamentModels(BaseModel): + model_config = ConfigDict(from_attributes=True) + title: str = Field(..., min_length=3) description: str start_date: datetime @@ -34,4 +36,6 @@ def check_dates(self) -> Self: class TournamentStatusOptionModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + name: str = Field(..., min_length=3) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index dcee402..17eb95b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,6 +1,9 @@ -from pydantic import BaseModel, Field, EmailStr, field_validator +from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict +from .role import RolePublic class UserBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + full_name: str = Field(..., description="Username") @field_validator("full_name") @@ -15,11 +18,11 @@ class UserUpdate(UserBase): class UserPublic(UserBase): email: EmailStr + roles: list[RolePublic] class UserModel(UserBase): email: EmailStr = Field(..., description="User email") - password: str = Field(..., min_length=6, description="User password") - role: list[str] + password: str = Field(..., description="User password") @field_validator("email") @classmethod @@ -27,5 +30,5 @@ def check_email(cls, value: str): return value.lower().strip() # Return notifications of current user only -class CurrentUser(UserModel): +class CurrentUser(UserPublic): notifications: list[str] \ No newline at end of file From aa85521c35506fa5bb07430cb7f76ad5bb1c5836 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 12 Mar 2026 12:39:54 +0200 Subject: [PATCH 044/369] feat(routes): implement role request flow --- backend/app/main.py | 2 + backend/app/routes/role_request.py | 32 ++++++--- backend/app/schemas/role_request.py | 1 + backend/tests/routes/test_role_request.py | 84 +++++++++++++++++++++++ 4 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 backend/tests/routes/test_role_request.py diff --git a/backend/app/main.py b/backend/app/main.py index 547ab03..12725a9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,8 +2,10 @@ import app.routes.tournaments as tournaments import app.routes.users as users import app.routes.profile as profile +import app.routes.role_request as role_request app = FastAPI() app.include_router(tournaments.router) app.include_router(users.router) app.include_router(profile.router) +app.include_router(role_request.router) diff --git a/backend/app/routes/role_request.py b/backend/app/routes/role_request.py index d43d0f6..6b8dbd8 100644 --- a/backend/app/routes/role_request.py +++ b/backend/app/routes/role_request.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, status, HTTPException, Depends +from fastapi.encoders import jsonable_encoder from typing import Annotated -from app.dependencies import SessionDep, CurrentUserDep +from app.dependencies import SessionDep, CurrentUserDep, get_current_user from sqlalchemy import select from app.models import RoleRequest, User, Role from app.schemas import RoleRequestPublic, RoleRequestCreate, UserPublic @@ -9,14 +10,16 @@ async def get_role_request(request_id: int, session: SessionDep) -> RoleRequest: statement = select(RoleRequest).where(RoleRequest.id==request_id) - request = await session.execute(statement) - if not request.first(): + result = await session.execute(statement) + request = result.scalar_one_or_none() + if not request: raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Role request not found!') + return request async def get_admin_user(current_user: CurrentUserDep, session: SessionDep) -> User: statement = select(User).where(User.id==current_user.id).filter(User.roles.contains(Role.name == 'admin')) - user = await session.execute(statement) + user = (await session.execute(statement)).scalar_one_or_none() if not user: raise HTTPException(status.HTTP_403_FORBIDDEN, detail='Permission denied!') return user @@ -28,34 +31,45 @@ async def get_admin_user(current_user: CurrentUserDep, session: SessionDep) -> U @router.post('/', response_model=RoleRequestPublic, - dependencies=[CurrentUserDep]) + dependencies=[Depends(get_current_user)]) async def create_request( session: SessionDep, request: RoleRequestCreate ): request = RoleRequestCreate.model_validate(request) + statement = select(RoleRequest).where(RoleRequest.role_id==request.role_id, + RoleRequest.user_id==request.user_id) + r = (await session.execute(statement)).scalar() + if r: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Role requests already exists!') + request = RoleRequest(**request.model_dump()) session.add(request) await session.commit() + await session.refresh(request) + return request + +@router.get('/{request_id}/', response_model=RoleRequestPublic) +async def get_request(request_id: int, session: SessionDep, request: RoleRequestDep): return request # TODO: implement role request dis/approval permission handling @router.post('/{request_id}/approve/', response_model=UserPublic) async def approve_request(request_id: int, request: RoleRequestDep, session: SessionDep): - request.user.roles.add(request.role) - await session.refresh(request.user) + request.user.roles.append(request.role) await session.delete(request) await session.commit() + await session.refresh(request.user) return request.user @router.post('/{request_id}/disapprove/', response_model=UserPublic) -async def approve_request(request_id: int, request: RoleRequestDep, session: SessionDep): +async def disapprove_request(request_id: int, request: RoleRequestDep, session: SessionDep): await session.delete(request) await session.commit() return request.user @router.delete('/{request_id}/', status_code=status.HTTP_204_NO_CONTENT, - dependencies=[CurrentUserDep]) + dependencies=[Depends(get_current_user)]) async def delete_request(request_id: int, request: RoleRequestDep, session: SessionDep, current_user: CurrentUserDep): await session.delete(request) await session.commit() \ No newline at end of file diff --git a/backend/app/schemas/role_request.py b/backend/app/schemas/role_request.py index efcc6a8..f3fb581 100644 --- a/backend/app/schemas/role_request.py +++ b/backend/app/schemas/role_request.py @@ -9,6 +9,7 @@ class RoleRequestBase(BaseModel): user_id: int = Field(..., description="User requesting the role") class RoleRequestPublic(RoleRequestBase): + id: int user: UserPublic role: RolePublic diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py new file mode 100644 index 0000000..84c2d4b --- /dev/null +++ b/backend/tests/routes/test_role_request.py @@ -0,0 +1,84 @@ +import pytest +from app.main import app +from app.dependencies import get_current_user +from app.routes.role_request import get_admin_user +from app.models import RoleRequest +from sqlalchemy import select + +@pytest.mark.asyncio +async def test_role_request_and_approval(user, role, client, db_session): + app.dependency_overrides[get_current_user] = lambda: user + app.dependency_overrides[get_admin_user] = lambda: user + assert len(user.roles) == 0 + resp = client.post('/role-requests/', json={ + 'role_id': role.id, + 'user_id': user.id, + }) + assert resp.status_code == 200 + assert resp.json()['role_id'] == role.id + assert resp.json()['user_id'] == user.id + req_id = resp.json()['id'] + s = select(RoleRequest) + assert len((await db_session.execute(s)).scalars().all()) == 1 + resp = client.post(f'/role-requests/{req_id}/approve/') + assert resp.status_code == 200 + assert len(resp.json()['roles']) == 1 + assert len((await db_session.execute(s)).scalars().all()) == 0 + app.dependency_overrides.pop(get_current_user) + app.dependency_overrides.pop(get_admin_user) + +@pytest.mark.asyncio +async def test_create_role_request_exists(user, role, client, db_session): + app.dependency_overrides[get_current_user] = lambda: user + r = RoleRequest(role_id=role.id, user_id=user.id) + db_session.add(r) + await db_session.commit() + resp = client.post('/role-requests/', json={ + 'role_id': role.id, + 'user_id': user.id, + }) + assert resp.status_code == 400 + assert 'exists' in resp.json()['detail'] + assert len(user.roles) == 0 + s = select(RoleRequest) + assert len((await db_session.execute(s)).scalars().all()) == 1 + app.dependency_overrides.pop(get_current_user) + +@pytest.mark.asyncio +async def test_role_request_disapproval(user, role, client, db_session): + app.dependency_overrides[get_current_user] = lambda: user + r = RoleRequest(role_id=role.id, user_id=user.id) + db_session.add(r) + await db_session.commit() + await db_session.refresh(r) + resp = client.post(f'/role-requests/{r.id}/disapprove/') + assert resp.status_code == 200 + assert len(user.roles) == 0 + s = select(RoleRequest) + assert len((await db_session.execute(s)).scalars().all()) == 0 + app.dependency_overrides.pop(get_current_user) + +@pytest.mark.asyncio +async def test_get_role_request(user, role, client, db_session): + app.dependency_overrides[get_current_user] = lambda: user + r = RoleRequest(role_id=role.id, user_id=user.id) + db_session.add(r) + await db_session.commit() + await db_session.refresh(r) + resp = client.get(f'/role-requests/{r.id}/') + assert resp.status_code == 200 + assert resp.json()['role_id'] == r.role_id + assert resp.json()['user_id'] == r.user_id + app.dependency_overrides.pop(get_current_user) + +@pytest.mark.asyncio +async def test_delete_role_request(user, role, client, db_session): + app.dependency_overrides[get_current_user] = lambda: user + r = RoleRequest(role_id=role.id, user_id=user.id) + db_session.add(r) + await db_session.commit() + await db_session.refresh(r) + resp = client.delete(f'/role-requests/{r.id}/') + assert resp.status_code == 204 + assert not (await db_session.execute(select(RoleRequest))).scalar() + app.dependency_overrides.pop(get_current_user) \ No newline at end of file From e3da16f3273f2684d5f4b1b2c9db07cc4ff56221 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 12 Mar 2026 12:45:47 +0200 Subject: [PATCH 045/369] refactor: move retrieving user and tournament logic into separate dependencies --- backend/app/main.py | 4 +-- .../{role_request.py => role_requests.py} | 4 +-- backend/app/routes/tournaments.py | 20 ++++++++----- backend/app/routes/users.py | 28 +++++++++++-------- backend/tests/routes/test_role_request.py | 2 +- backend/tests/test_models.py | 2 +- 6 files changed, 36 insertions(+), 24 deletions(-) rename backend/app/routes/{role_request.py => role_requests.py} (96%) diff --git a/backend/app/main.py b/backend/app/main.py index 12725a9..be19905 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,10 +2,10 @@ import app.routes.tournaments as tournaments import app.routes.users as users import app.routes.profile as profile -import app.routes.role_request as role_request +import backend.app.routes.role_requests as role_requests app = FastAPI() app.include_router(tournaments.router) app.include_router(users.router) app.include_router(profile.router) -app.include_router(role_request.router) +app.include_router(role_requests.router) diff --git a/backend/app/routes/role_request.py b/backend/app/routes/role_requests.py similarity index 96% rename from backend/app/routes/role_request.py rename to backend/app/routes/role_requests.py index 6b8dbd8..b31e0b1 100644 --- a/backend/app/routes/role_request.py +++ b/backend/app/routes/role_requests.py @@ -11,7 +11,7 @@ async def get_role_request(request_id: int, session: SessionDep) -> RoleRequest: statement = select(RoleRequest).where(RoleRequest.id==request_id) result = await session.execute(statement) - request = result.scalar_one_or_none() + request = result.scalar() if not request: raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Role request not found!') @@ -19,7 +19,7 @@ async def get_role_request(request_id: int, session: SessionDep) -> RoleRequest: async def get_admin_user(current_user: CurrentUserDep, session: SessionDep) -> User: statement = select(User).where(User.id==current_user.id).filter(User.roles.contains(Role.name == 'admin')) - user = (await session.execute(statement)).scalar_one_or_none() + user = (await session.execute(statement)).scalar() if not user: raise HTTPException(status.HTTP_403_FORBIDDEN, detail='Permission denied!') return user diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 16913f2..d95f57e 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, status, Depends +from typing import Annotated from app.schemas import TournamentModels from app.models import Tournament from app.dependencies import SessionDep @@ -6,6 +7,15 @@ router = APIRouter(prefix='/tournaments', tags=['tournaments']) +async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: + statement = select(Tournament).where(Tournament.id==tournament_id) + tournament = (await session.execute(statement)).scalar() + if not tournament: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') + return tournament + +TournamentDep = Annotated[Tournament, Depends(get_tournament)] + @router.get('/', response_model=list[TournamentModels]) async def tournaments(session: SessionDep): statement = select(Tournament) @@ -13,9 +23,5 @@ async def tournaments(session: SessionDep): return tournaments.all() @router.get('/{tournament_id}/', response_model=TournamentModels) -async def tournament(tournament_id: int, session: SessionDep): - statement = select(Tournament).where(Tournament.id==tournament_id) - tournament = await session.execute(statement) - if not tournament.first(): - raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') - return tournament.first() \ No newline at end of file +async def tournament(tournament_id: int, session: SessionDep, tournament: TournamentDep): + return tournament \ No newline at end of file diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index 769776a..2cf81a4 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -1,21 +1,27 @@ -from fastapi import APIRouter, HTTPException, status -from app.schemas import UserModel +from fastapi import APIRouter, HTTPException, status, Depends +from typing import Annotated +from app.schemas import UserPublic from app.models import User from app.dependencies import SessionDep from sqlalchemy import select router = APIRouter(prefix='/users', tags=['users']) -@router.get('/', response_model=list[UserModel]) +async def get_user(user_id: int, session: SessionDep): + statement = select(User).where(User.id==user_id) + user = (await session.execute(statement)).scalar() + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') + return user + +UserDep = Annotated[User, Depends(get_user)] + +@router.get('/', response_model=list[UserPublic]) async def users(session: SessionDep): statement = select(User) users = await session.execute(statement) - return users.all() + return users.scalars().all() -@router.get('/{user_id}/', response_model=UserModel) -async def user(user_id: int, session: SessionDep): - statement = select(User).where(User.id==user_id) - user = await session.execute(statement) - if not user.first(): - raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') - return user.first() \ No newline at end of file +@router.get('/{user_id}/', response_model=UserPublic) +async def user(user_id: int, user: UserDep, session: SessionDep): + return user \ No newline at end of file diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index 84c2d4b..fe0f855 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -1,7 +1,7 @@ import pytest from app.main import app from app.dependencies import get_current_user -from app.routes.role_request import get_admin_user +from backend.app.routes.role_requests import get_admin_user from app.models import RoleRequest from sqlalchemy import select diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index d729468..38dc4d3 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -185,7 +185,7 @@ async def test_team_cascade_delete_members(db_session, team, team_member): stmt = select(TeamMember).where(TeamMember.id == member_id) result = await db_session.execute(stmt) - assert result.scalar_one_or_none() is None + assert result.scalar() is None async def test_create_task(task, tournament): From 2787d620b7a8d8c4e3704a5de25414009e35c8a2 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 12 Mar 2026 12:46:18 +0200 Subject: [PATCH 046/369] chore: silence sqlalchemy echo --- backend/app/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/db.py b/backend/app/db.py index 0af2148..0437784 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,7 +1,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from app.config import settings -engine = create_async_engine(settings.SQLALCHEMY_DATABASE_URI, echo=True) +engine = create_async_engine(settings.SQLALCHEMY_DATABASE_URI) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) From 7bb30df85094622d3d2871e2ab730eef4bab3633 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 12 Mar 2026 20:19:24 +0200 Subject: [PATCH 047/369] fix: no module named 'backend' --- backend/app/main.py | 2 +- backend/tests/routes/test_role_request.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index be19905..471fec6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ import app.routes.tournaments as tournaments import app.routes.users as users import app.routes.profile as profile -import backend.app.routes.role_requests as role_requests +import app.routes.role_requests as role_requests app = FastAPI() app.include_router(tournaments.router) diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index fe0f855..8af3ded 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -1,7 +1,7 @@ import pytest from app.main import app from app.dependencies import get_current_user -from backend.app.routes.role_requests import get_admin_user +from app.routes.role_requests import get_admin_user from app.models import RoleRequest from sqlalchemy import select From d6bedb4818c57ab5e1e6614c3ce5f9ad8641f1a6 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 12 Mar 2026 19:53:43 +0000 Subject: [PATCH 048/369] Wrote tests for SubmissionEvaluation and RequirementEvaluation models --- backend/app/models/evaluation.py | 9 +-- backend/tests/factories.py | 18 ++++++ backend/tests/test_models.py | 95 ++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index a1543be..2f18b3a 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -1,4 +1,4 @@ -from sqlalchemy import ForeignKey, Table, Column +from sqlalchemy import ForeignKey, Table, Column, UniqueConstraint from sqlalchemy.orm import mapped_column, Mapped, relationship from .base import Base @@ -22,10 +22,11 @@ class SubmissionEvaluation(Base, PKMixin): __tablename__ = "evaluations" + __table_args__ = (UniqueConstraint("submission_id", "jury_id"),) submission_id: Mapped[int] = mapped_column( - ForeignKey("submissions.team_id"), primary_key=True + ForeignKey("submissions.team_id"), nullable=False ) - jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) submission: Mapped["Submission"] = relationship(back_populates="evaluations") jury: Mapped["User"] = relationship() @@ -37,7 +38,7 @@ class SubmissionEvaluation(Base, PKMixin): class RequirementEvaluation(Base, PKMixin): __tablename__ = "requirement_evaluations" evaluation_id: Mapped[int] = mapped_column( - ForeignKey("evaluations.id"), primary_key=True + ForeignKey("evaluations.id"), nullable=False ) evaluation: Mapped["SubmissionEvaluation"] = relationship( back_populates="requirement_evaluations" diff --git a/backend/tests/factories.py b/backend/tests/factories.py index b3b4628..d8a2440 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -16,6 +16,8 @@ Submission, SubmissionUrl, SubmissionUrlOption, + SubmissionEvaluation, + RequirementEvaluation, ) @@ -153,3 +155,19 @@ class Meta: submission = factory.SubFactory(SubmissionFactory) url = factory.SubFactory(SubmissionUrlOptionFactory) + + +class SubmissionEvaluationFactory(BaseFactory): + class Meta: + model = SubmissionEvaluation + + submission = factory.SubFactory(SubmissionFactory) + jury = factory.SubFactory(UserFactory) + + +class RequirementEvaluationFactory(BaseFactory): + class Meta: + model = RequirementEvaluation + + evaluation = factory.SubFactory(SubmissionEvaluationFactory) + score = factory.Faker("pyint", min_value=0, max_value=100) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 8547972..861b9c5 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -14,6 +14,8 @@ Submission, SubmissionUrl, SubmissionUrlOption, + SubmissionEvaluation, + RequirementEvaluation, ) from .factories import ( @@ -30,6 +32,8 @@ SubmissionFactory, SubmissionUrlFactory, SubmissionUrlOptionFactory, + SubmissionEvaluationFactory, + RequirementEvaluationFactory, ) @@ -445,3 +449,94 @@ async def test_submission_url_option_values(create): assert option.name.startswith("url_option_") assert option.display_name == option.name.upper() + + +# EVALUATION TESTS +async def test_create_submission_evaluation(create): + evaluation = await create(SubmissionEvaluationFactory) + + assert evaluation.id is not None + assert evaluation.submission is not None + assert evaluation.jury is not None + + +async def test_create_requirement_evaluation(create): + evaluation = await create(RequirementEvaluationFactory) + + assert evaluation.evaluation_id is not None + assert evaluation.evaluation is not None + assert 0 <= evaluation.score <= 100 + + +async def test_evaluation_multiple_requirements(create): + evaluation = await create(SubmissionEvaluationFactory) + + req1 = await create(RequirementEvaluationFactory, evaluation=evaluation) + req2 = await create(RequirementEvaluationFactory, evaluation=evaluation) + + assert req1.evaluation_id == evaluation.id + assert req2.evaluation_id == evaluation.id + + +async def test_submission_evaluations_relationship(db_session, create): + submission = await create(SubmissionFactory) + + await create(SubmissionEvaluationFactory, submission=submission) + await create(SubmissionEvaluationFactory, submission=submission) + + stmt = ( + select(Submission) + .where(Submission.team_id == submission.team_id) + .options(selectinload(Submission.evaluations)) + ) + + result = await db_session.execute(stmt) + db_submission = result.scalar_one() + + assert len(db_submission.evaluations) == 2 + + +async def test_judge_cannot_evaluate_twice(create): + evaluation = await create(SubmissionEvaluationFactory) + + with pytest.raises(IntegrityError): + await create( + SubmissionEvaluationFactory, + submission=evaluation.submission, + jury=evaluation.jury, + ) + + +async def test_evaluation_jury_relationship(db_session, create): + evaluation = await create(SubmissionEvaluationFactory) + + stmt = ( + select(SubmissionEvaluation) + .where(SubmissionEvaluation.id == evaluation.id) + .options(selectinload(SubmissionEvaluation.jury)) + ) + + result = await db_session.execute(stmt) + db_eval = result.scalar_one() + + assert db_eval.jury.id == evaluation.jury.id + + +async def test_requirement_evaluation_option_relationship(db_session, create): + option = await create(TaskRequirementOptionFactory) + + req_eval = await create( + RequirementEvaluationFactory, + requirement=[option], + ) + + stmt = ( + select(RequirementEvaluation) + .where(RequirementEvaluation.id == req_eval.id) + .options(selectinload(RequirementEvaluation.requirement)) + ) + + result = await db_session.execute(stmt) + db_req_eval = result.scalar_one() + + assert db_req_eval.requirement[0].name == option.name From 433bd52920dcf879f6c925abe49fdd1abea26201 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 12 Mar 2026 22:23:21 +0200 Subject: [PATCH 049/369] feat: implement firebase auth without creating user model in local db --- frontend/package-lock.json | 1306 +++++++++++++++++++++++- frontend/package.json | 2 + frontend/src/App.tsx | 10 +- frontend/src/components/Header.tsx | 17 +- frontend/src/firebase.ts | 37 + frontend/src/index.css | 1 + frontend/src/main.tsx | 6 +- frontend/src/pages/Auth/Auth.tsx | 3 - frontend/src/pages/Auth/SignIn.tsx | 17 + frontend/src/pages/Auth/SignOut.tsx | 15 + frontend/src/pages/Auth/SignUp.tsx | 17 + frontend/src/pages/Profile/Profile.tsx | 7 +- 12 files changed, 1415 insertions(+), 23 deletions(-) create mode 100644 frontend/src/firebase.ts delete mode 100644 frontend/src/pages/Auth/Auth.tsx create mode 100644 frontend/src/pages/Auth/SignIn.tsx create mode 100644 frontend/src/pages/Auth/SignOut.tsx create mode 100644 frontend/src/pages/Auth/SignUp.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e1cdc9..1c159f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@firebase-oss/ui-react": "^7.0.2-beta", "@lottiefiles/react-lottie-player": "^3.6.0", "@tailwindcss/vite": "^4.2.1", + "firebase": "^12.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -61,6 +63,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -885,6 +888,717 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@firebase-oss/ui-core": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-core/-/ui-core-7.0.2-beta.tgz", + "integrity": "sha512-pm6u75QU/0BaDC7Z9qbRw/hHrUXUMXLzOgtvWHeH4+EtuJRMiC6JI+g/O2Pmenz4o5rKhSZfC3d58+6j0g5lOA==", + "license": "MIT", + "dependencies": { + "@firebase-oss/ui-translations": "7.0.2-beta", + "@nanostores/deepmap": "^0.0.1", + "libphonenumber-js": "^1.12.23", + "nanostores": "^1.1.0", + "qrcode-generator": "^2.0.4", + "zod": "4.1.12" + }, + "peerDependencies": { + "firebase": "^11 || ^12" + } + }, + "node_modules/@firebase-oss/ui-core/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@firebase-oss/ui-react": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-react/-/ui-react-7.0.2-beta.tgz", + "integrity": "sha512-Kuxcu7nEe5XVBLxSKJfRuaU7VnbGKgMl0X4m3VKDfLqfCoXyj8BwhynXeX3r+JH7sQXe4DflgkPUvnekVNEyTg==", + "dependencies": { + "@firebase-oss/ui-core": "7.0.2-beta", + "@firebase-oss/ui-styles": "7.0.2-beta", + "@nanostores/react": "^1.0.0", + "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-form": "1.20.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.0.1", + "zod": "4.1.12" + }, + "peerDependencies": { + "firebase": "^11 || ^12", + "react": "^19", + "react-dom": "^19" + } + }, + "node_modules/@firebase-oss/ui-react/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@firebase-oss/ui-styles": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-styles/-/ui-styles-7.0.2-beta.tgz", + "integrity": "sha512-o3MS/fe7072FlhpjCf0GAoY4Q1UravGYuHEDrauqgjjWcNJANGx9w8zXr0eNPJyRdneawZLd9iIQt/Lwu33mxQ==", + "dependencies": { + "cva": "1.0.0-beta.4" + } + }, + "node_modules/@firebase-oss/ui-translations": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-translations/-/ui-translations-7.0.2-beta.tgz", + "integrity": "sha512-DFMcCJZv6qlaxPbkd3tUjn1ngT7i9Z31T2K9S1OawtlmWlwy7A7Lxon6g1OFSzc3mmUssoC7v0/muB8A3g2vTg==" + }, + "node_modules/@firebase/ai": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.9.0.tgz", + "integrity": "sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.20", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.20.tgz", + "integrity": "sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.26", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz", + "integrity": "sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.20", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.14.9", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", + "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.1.tgz", + "integrity": "sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz", + "integrity": "sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.1", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", + "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app": "0.14.9", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/auth": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.1.tgz", + "integrity": "sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.3.tgz", + "integrity": "sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.12.1", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", + "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.4.0.tgz", + "integrity": "sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", + "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", + "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/database": "1.1.1", + "@firebase/database-types": "1.0.17", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", + "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.14.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.12.0.tgz", + "integrity": "sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz", + "integrity": "sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/firestore": "4.12.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.2.tgz", + "integrity": "sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.2.tgz", + "integrity": "sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/functions": "0.13.2", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.20", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.20.tgz", + "integrity": "sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.20.tgz", + "integrity": "sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.24", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.24.tgz", + "integrity": "sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.14.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz", + "integrity": "sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/messaging": "0.12.24", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.10.tgz", + "integrity": "sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.23.tgz", + "integrity": "sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.10", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.1.tgz", + "integrity": "sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz", + "integrity": "sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.1", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.1.tgz", + "integrity": "sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.1.tgz", + "integrity": "sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/storage": "0.14.1", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", + "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -994,6 +1708,134 @@ "react": "16 - 19" } }, + "node_modules/@nanostores/deepmap": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@nanostores/deepmap/-/deepmap-0.0.1.tgz", + "integrity": "sha512-Wp7elfx/naWM61XOzC+qZOYQ565VVtUAU7NQSgjORRVSKn57U31GYHpn3TKImW7Vdfa9TAykIjzB7JVjH2L4fw==", + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^1.0.0" + } + }, + "node_modules/@nanostores/react": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.0.0.tgz", + "integrity": "sha512-eDduyNy+lbQJMg6XxZ/YssQqF6b4OXMFEZMYKPJCCmBevp1lg0g+4ZRi94qGHirMtsNfAWKNwsjOhC+q1gvC+A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1583,6 +2425,72 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/form-core": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.20.0.tgz", + "integrity": "sha512-FGlKvcsusOf4756vtN1EoDI4h50r4/11eTcpF3NcnE04N/bSn2gP7cdhG6tYA0lJWzM9H1pNIzZ86uZ4MHB9eA==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.7.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.20.0.tgz", + "integrity": "sha512-1UfWqEYRnHr4cooGbHiTQqoqus8soNUH+RLD6UyhIQEvomOSQMX0JgX+zGSl08tIugrnWcAnh50n5T9IIs/Evw==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.20.0", + "@tanstack/react-store": "^0.7.4", + "decode-formdata": "^0.9.0", + "devalue": "^5.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-start": "^1.130.10", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.7.7", + "use-sync-external-store": "^1.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz", + "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1645,7 +2553,6 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1655,8 +2562,9 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1716,6 +2624,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -1980,6 +2889,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2014,11 +2924,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2085,6 +3003,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2147,11 +3066,33 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2164,7 +3105,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2213,9 +3153,29 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/cva": { + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/cva/-/cva-1.0.0-beta.4.tgz", + "integrity": "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + }, + "peerDependencies": { + "typescript": ">= 4.5.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2234,6 +3194,12 @@ } } }, + "node_modules/decode-formdata": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz", + "integrity": "sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2250,6 +3216,12 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2257,6 +3229,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -2315,7 +3293,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2340,6 +3317,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2539,6 +3517,18 @@ "dev": true, "license": "MIT" }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2586,6 +3576,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz", + "integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/ai": "2.9.0", + "@firebase/analytics": "0.10.20", + "@firebase/analytics-compat": "0.2.26", + "@firebase/app": "0.14.9", + "@firebase/app-check": "0.11.1", + "@firebase/app-check-compat": "0.4.1", + "@firebase/app-compat": "0.5.9", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.12.1", + "@firebase/auth-compat": "0.6.3", + "@firebase/data-connect": "0.4.0", + "@firebase/database": "1.1.1", + "@firebase/database-compat": "2.1.1", + "@firebase/firestore": "4.12.0", + "@firebase/firestore-compat": "0.4.6", + "@firebase/functions": "0.13.2", + "@firebase/functions-compat": "0.4.2", + "@firebase/installations": "0.6.20", + "@firebase/installations-compat": "0.2.20", + "@firebase/messaging": "0.12.24", + "@firebase/messaging-compat": "0.2.24", + "@firebase/performance": "0.7.10", + "@firebase/performance-compat": "0.2.23", + "@firebase/remote-config": "0.8.1", + "@firebase/remote-config-compat": "0.2.22", + "@firebase/storage": "0.14.1", + "@firebase/storage-compat": "0.4.1", + "@firebase/util": "1.14.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2631,6 +3658,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2690,6 +3726,18 @@ "hermes-estree": "0.25.1" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2737,6 +3785,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2857,6 +3914,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.39", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.39.tgz", + "integrity": "sha512-MW79m7HuOqBk8mwytiXYTMELJiBbV3Zl9Y39dCCn1yC8K+WGNSq1QGvzywbylp5vGShEztMScCWHX/XFOS0rXg==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -3122,6 +4185,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3129,6 +4198,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lottie-web": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", @@ -3192,6 +4267,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.1.tgz", + "integrity": "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3300,6 +4391,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3345,6 +4437,30 @@ "node": ">= 0.8.0" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3355,11 +4471,18 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3369,6 +4492,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3424,6 +4548,15 @@ "react-dom": ">=18" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3478,6 +4611,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3532,6 +4685,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3558,6 +4737,16 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -3606,6 +4795,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3623,8 +4818,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3661,7 +4857,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -3705,11 +4900,21 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3779,6 +4984,35 @@ } } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3805,6 +5039,32 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3812,6 +5072,33 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3831,6 +5118,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 83f9974..2a2d3a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@firebase-oss/ui-react": "^7.0.2-beta", "@lottiefiles/react-lottie-player": "^3.6.0", "@tailwindcss/vite": "^4.2.1", + "firebase": "^12.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d45191..2cb532e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,25 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import { Home } from "./pages/Home/Home"; -import { Auth } from "./pages/Auth/Auth"; import { Profile } from "./pages/Profile/Profile"; import { TournamentsPage } from "./pages/TournamentsPage/TournamentsPage"; import { TournamentPage } from "./pages/TournamentPage/TournamentPage"; import { Page404 } from "./pages/Page404/Page404"; +import SignIn from "./pages/Auth/SignIn"; +import SignUp from "./pages/Auth/SignUp"; +import SignOut from "./pages/Auth/SignOut"; export const App = () => { return ( } /> - } /> } /> } /> + + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 64980fd..b604b4f 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { getAuth } from "firebase/auth"; import { Link, NavLink } from "react-router-dom"; export const Header = () => { @@ -7,6 +8,8 @@ export const Header = () => { { path: "/join", label: "Як долучитись" }, { path: "/rating", label: "Рейтинг" }, ]; + const auth = getAuth(); + const user = auth.currentUser; return (
@@ -24,8 +27,7 @@ export const Header = () => { key={item.path} to={item.path} className={({ isActive }) => - `group relative font-semibold text-[16px] py-2 transition-colors duration-300 ${ - isActive ? "text-accent" : "text-white hover:text-accent" + `group relative font-semibold text-[16px] py-2 transition-colors duration-300 ${isActive ? "text-accent" : "text-white hover:text-accent" }` } > @@ -33,9 +35,8 @@ export const Header = () => { <> {item.label} )} @@ -43,9 +44,11 @@ export const Header = () => { ))} - + {user ? + Профіль + : Увійти - + }
); diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts new file mode 100644 index 0000000..256d1e0 --- /dev/null +++ b/frontend/src/firebase.ts @@ -0,0 +1,37 @@ +// Import the functions you need from the SDKs you need +import { initializeApp } from "firebase/app"; +import { getAnalytics } from "firebase/analytics"; +import { getAuth, onAuthStateChanged } from "firebase/auth"; +import { initializeUI, requireDisplayName } from '@firebase-oss/ui-core'; + +const firebaseConfig = { + apiKey: "AIzaSyDYEFX52SBP1Z4H64li_BD9a-TnrB-8FQ8", + authDomain: "tournament-project-9a31a.firebaseapp.com", + projectId: "tournament-project-9a31a", + storageBucket: "tournament-project-9a31a.firebasestorage.app", + messagingSenderId: "92371798617", + appId: "1:92371798617:web:7819c49a21f61a3167bf48", + measurementId: "G-7BEXQ5VZHD" +}; + +const app = initializeApp(firebaseConfig); +const analytics = getAnalytics(app); +const auth = getAuth(app); +const ui = initializeUI({ + app, + behaviors: [ + requireDisplayName(), + ], +}); + +onAuthStateChanged(auth, (user) => { + if (user) { + const uid = user.uid; + console.log(uid); + } else { + console.log('Sign out!') + } +}); + +export default app; +export { analytics, auth, ui }; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 8252aa6..ede06c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "@firebase-oss/ui-styles/tailwind"; @theme { --font-inter: "Inter", sans-serif; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 861daaf..f43f99e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; +import { ui } from './firebase'; import { App } from "./App"; +import { FirebaseUIProvider } from "@firebase-oss/ui-react"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/frontend/src/pages/Auth/Auth.tsx b/frontend/src/pages/Auth/Auth.tsx deleted file mode 100644 index f2e62df..0000000 --- a/frontend/src/pages/Auth/Auth.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const Auth = () => { - return

pahe aut

; -}; diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx new file mode 100644 index 0000000..c918110 --- /dev/null +++ b/frontend/src/pages/Auth/SignIn.tsx @@ -0,0 +1,17 @@ +import { SignInAuthScreen } from "@firebase-oss/ui-react"; +import { Link, useNavigate } from "react-router-dom"; + + +const SignIn = () => { + const navigate = useNavigate(); + const handleSignIn = () => { + navigate('/'); + }; + return <> + + Не маєте аккаунту? Створіть його! + + ; +} + +export default SignIn; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignOut.tsx b/frontend/src/pages/Auth/SignOut.tsx new file mode 100644 index 0000000..c4c16ed --- /dev/null +++ b/frontend/src/pages/Auth/SignOut.tsx @@ -0,0 +1,15 @@ +import { getAuth, signOut } from "firebase/auth"; +import { useEffect } from "react"; +import { Navigate } from "react-router-dom"; + +const SignOut = () => { + useEffect(() => { + const auth = getAuth(); + signOut(auth).catch((error) => { + console.log(error); + }); + }, []); + return ; +} + +export default SignOut; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx new file mode 100644 index 0000000..1d104d4 --- /dev/null +++ b/frontend/src/pages/Auth/SignUp.tsx @@ -0,0 +1,17 @@ +import { SignUpAuthScreen } from "@firebase-oss/ui-react"; +import { Link, useNavigate } from "react-router-dom"; + + +const SignUp = () => { + const navigate = useNavigate(); + const handleSignUp = () => { + navigate('/auth/sign-in/'); + }; + return <> + + Вже маєте аккаунт? Увійдіть у нього! + + ; +} + +export default SignUp; \ No newline at end of file diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index e0b5821..22955c6 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,3 +1,8 @@ +import { getAuth } from "firebase/auth"; + export const Profile = () => { - return

Сторінка для профілю

; + const auth = getAuth(); + const user = auth.currentUser; + + return

Вітаю, {user?.displayName}

; }; From e031bcb57e89daedc67bd279b0c29757d9118d9f Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 12 Mar 2026 20:50:50 +0000 Subject: [PATCH 050/369] Wrote tests for Notification and RoleRequest models + wrote RoleRequestFactory and NotificationFactory --- backend/app/models/__init__.py | 2 + backend/app/models/notification.py | 24 +++---- backend/app/models/role.py | 6 +- backend/app/models/role_request.py | 25 ++++--- backend/app/models/user.py | 4 +- backend/tests/factories.py | 18 +++++ backend/tests/test_models.py | 102 +++++++++++++++++++++++++++++ 7 files changed, 153 insertions(+), 28 deletions(-) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1b29b3e..752f0e8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,7 @@ from .base import Base from .evaluation import SubmissionEvaluation, RequirementEvaluation +from .notification import Notification +from .role_request import RoleRequest from .role import Role from .submission import Submission, SubmissionUrl, SubmissionUrlOption from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index e713f25..492c8e5 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -1,17 +1,19 @@ -# from sqlalchemy.orm import Mapped, mapped_column, relationship -# from sqlalchemy import String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey -# from .base import Base -# from .mixin import PKMixin +from .base import Base +from .mixin import PKMixin -# class Notification(Base, PKMixin): -# __tablename__ = "notifications" +class Notification(Base, PKMixin): + __tablename__ = "notifications" -# body: Mapped[str] = mapped_column(String(4096), unique=True) -# user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + body: Mapped[str] = mapped_column(String(4096), unique=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) -# user: Mapped["User"] = relationship("User", back_populates="notifications") + user: Mapped["User"] = relationship( + "User", back_populates="notifications", lazy="selectin" + ) -# def __repr__(self): -# return f"" + def __repr__(self): + return f"" diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 79c07b4..7781eb2 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -11,9 +11,11 @@ class Role(Base, PKMixin): name: Mapped[str] = mapped_column(nullable=False, unique=True) users: Mapped[list["User"]] = relationship( - secondary=user_roles, back_populates="roles" + secondary=user_roles, back_populates="roles", lazy="selectin" + ) + requests: Mapped[list["RoleRequest"]] = relationship( + back_populates="role", lazy="selectin" ) - # requests: Mapped[list["RoleRequest"]] = relationship(back_populates="role") def __repr__(self): return f"" diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py index 8795e81..802a107 100644 --- a/backend/app/models/role_request.py +++ b/backend/app/models/role_request.py @@ -1,19 +1,18 @@ -# from sqlalchemy.orm import Mapped, mapped_column, relationship -# from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey -# from .base import Base -# from .mixin import PKMixin +from .base import Base +from .mixin import PKMixin +class RoleRequest(Base, PKMixin): + __tablename__ = "role_requests" -# class RoleRequest(Base, PKMixin): -# __tablename__ = "role_requests" + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) -# role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) -# user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + role: Mapped["Role"] = relationship(back_populates="requests", lazy="selectin") + user: Mapped["User"] = relationship(back_populates="role_requests", lazy="selectin") -# role: Mapped["Role"] = relationship(back_populates="requests") -# user: Mapped["User"] = relationship(back_populates="role_requests") - -# def __repr__(self): -# return f"" + def __repr__(self): + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 13d7b99..bb2a6ed 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -24,8 +24,8 @@ class User(Base, PKMixin): roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users" ) - # notifications: Mapped[list["Notification"]] = relationship(back_populates="user") - # role_requests: Mapped[list["RoleRequest"]] = relationship(back_populates="user") + notifications: Mapped[list["Notification"]] = relationship(back_populates="user") + role_requests: Mapped[list["RoleRequest"]] = relationship(back_populates="user") created_tournaments: Mapped[list["Tournament"]] = relationship( back_populates="creator" ) diff --git a/backend/tests/factories.py b/backend/tests/factories.py index d8a2440..806afa2 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -18,6 +18,8 @@ SubmissionUrlOption, SubmissionEvaluation, RequirementEvaluation, + Notification, + RoleRequest, ) @@ -171,3 +173,19 @@ class Meta: evaluation = factory.SubFactory(SubmissionEvaluationFactory) score = factory.Faker("pyint", min_value=0, max_value=100) + + +class NotificationFactory(BaseFactory): + class Meta: + model = Notification + + body = factory.Sequence(lambda n: f"notification_{n}") + user = factory.SubFactory(UserFactory) + + +class RoleRequestFactory(BaseFactory): + class Meta: + model = RoleRequest + + role = factory.SubFactory(RoleFactory) + user = factory.SubFactory(UserFactory) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 861b9c5..b60c00e 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import selectinload from app.models import ( User, + Role, TeamMember, Team, Task, @@ -16,6 +17,8 @@ SubmissionUrlOption, SubmissionEvaluation, RequirementEvaluation, + Notification, + RoleRequest, ) from .factories import ( @@ -34,6 +37,8 @@ SubmissionUrlOptionFactory, SubmissionEvaluationFactory, RequirementEvaluationFactory, + NotificationFactory, + RoleRequestFactory, ) @@ -540,3 +545,100 @@ async def test_requirement_evaluation_option_relationship(db_session, create): db_req_eval = result.scalar_one() assert db_req_eval.requirement[0].name == option.name + + +# NOTIFICATION TESTS +async def test_create_notification(create): + notification = await create(NotificationFactory) + + assert notification.body is not None + assert notification.user is not None + + +async def test_create_notification_without_data(db_session): + notification = Notification() + db_session.add(notification) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() + + +async def test_user_notifications_relationship(db_session, create): + user = await create(UserFactory) + await create(NotificationFactory, user=user) + await create(NotificationFactory, user=user) + + stmt = ( + select(User).where(User.id == user.id).options(selectinload(User.notifications)) + ) + result = await db_session.execute(stmt) + db_user = result.scalar_one() + + assert len(db_user.notifications) == 2 + + +async def test_notification_body_unique(db_session, create): + notification = await create(NotificationFactory) + + with pytest.raises(IntegrityError): + await create( + NotificationFactory, + body=notification.body, + ) + + +async def test_notification_user_relationship(db_session, create): + notification = await create(NotificationFactory) + + stmt = ( + select(Notification) + .where(Notification.id == notification.id) + .options(selectinload(Notification.user)) + ) + + result = await db_session.execute(stmt) + db_notification = result.scalar_one() + + assert db_notification.user.id == notification.user.id + + +# ROLE REQUEST TESTS +async def test_create_role_request(create): + role_request = await create(RoleRequestFactory) + + assert role_request.role is not None + assert role_request.user is not None + + +async def test_create_role_request_without_data(db_session): + role_request = RoleRequest() + db_session.add(role_request) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() + + +async def test_user_role_requests_relationship(db_session, create): + user = await create(UserFactory) + await create(RoleRequestFactory, user=user) + await create(RoleRequestFactory, user=user) + + stmt = ( + select(User).where(User.id == user.id).options(selectinload(User.role_requests)) + ) + result = await db_session.execute(stmt) + db_user = result.scalar_one() + + assert len(db_user.role_requests) == 2 + + +async def test_role_requests_relationship(db_session, create): + role = await create(RoleFactory) + await create(RoleRequestFactory, role=role) + await create(RoleRequestFactory, role=role) + + stmt = select(Role).where(Role.id == role.id).options(selectinload(Role.requests)) + result = await db_session.execute(stmt) + db_role = result.scalar_one() + + assert len(db_role.requests) == 2 From f90562af244cd5903cf5ed234bec0cae7039acea Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 12 Mar 2026 21:21:36 +0000 Subject: [PATCH 051/369] Updated the requirements.txt file --- requirements.txt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 09f3356..1ccd42c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,13 @@ aiosqlite==0.22.1 -alembic==1.18.4 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 -async-timeout==5.0.1 -asyncpg==0.31.0 -backports.asyncio.runner==1.2.0 certifi==2026.1.4 click==8.3.1 dnspython==2.8.0 email-validator==2.3.0 -exceptiongroup==1.3.1 +factory_boy==3.3.3 +Faker==40.5.1 fastapi==0.129.0 fastapi-cli==0.0.23 fastapi-cloud-cli==0.13.0 @@ -23,12 +20,10 @@ httpx==0.28.1 idna==3.11 iniconfig==2.3.0 Jinja2==3.1.6 -Mako==1.3.10 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 packaging==26.0 -phonenumbers==9.0.25 pluggy==1.6.0 pydantic==2.12.5 pydantic-extra-types==2.11.0 @@ -37,8 +32,6 @@ pydantic_core==2.41.5 Pygments==2.19.2 pytest==9.0.2 pytest-asyncio==1.3.0 -pytest-mock==3.15.1 -pytest_async==0.1.1 python-dotenv==1.2.1 python-multipart==0.0.22 PyYAML==6.0.3 @@ -49,7 +42,6 @@ sentry-sdk==2.53.0 shellingham==1.5.4 SQLAlchemy==2.0.46 starlette==0.52.1 -tomli==2.4.0 typer==0.24.0 typing-inspection==0.4.2 typing_extensions==4.15.0 From dfbd5cb335db7f1ef06023c2c5c588acf753f310 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 12 Mar 2026 22:52:33 +0000 Subject: [PATCH 052/369] small fix --- backend/app/models/evaluation.py | 6 ++++-- backend/app/models/role_request.py | 22 ---------------------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index bc6aad2..909b28b 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -28,9 +28,11 @@ class SubmissionEvaluation(Base, PKMixin): ) jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - submission: Mapped["Submission"] = relationship(back_populates="evaluations", lazy="selectin") + submission: Mapped["Submission"] = relationship( + back_populates="evaluations", lazy="selectin" + ) jury: Mapped["User"] = relationship(lazy="selectin") - evaluations: Mapped[list["RequirementEvaluation"]] = relationship( + requirement_evaluations: Mapped[list["RequirementEvaluation"]] = relationship( back_populates="evaluation", lazy="selectin" ) diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py index 164c7da..87c4a61 100644 --- a/backend/app/models/role_request.py +++ b/backend/app/models/role_request.py @@ -18,25 +18,3 @@ def __repr__(self): return f"" from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy import ForeignKey - -from .base import Base -from .mixin import PKMixin -from .user import User -from .role import Role - - -class RoleRequest(Base, PKMixin): - __tablename__ = "role_requests" - - role_id: Mapped[int] = mapped_column(ForeignKey('roles.id')) - user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) - - role: Mapped["Role"] = relationship( - back_populates="requests", lazy="selectin" - ) - user: Mapped["User"] = relationship( - back_populates="role_requests", lazy="selectin" - ) - - def __repr__(self): - return f"" From b11ddd7c49e87ba257c342a74529b2a97030ef1f Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Fri, 13 Mar 2026 11:44:05 +0200 Subject: [PATCH 053/369] feat: implement google popup oauth --- frontend/src/components/Header.tsx | 3 +-- frontend/src/firebase.ts | 16 +++++++++++----- frontend/src/pages/Auth/SignIn.tsx | 4 +++- frontend/src/pages/Auth/SignOut.tsx | 4 ++-- frontend/src/pages/Auth/SignUp.tsx | 4 +++- .../pages/Home/components/TournamentSlider.tsx | 2 +- frontend/src/pages/Profile/Profile.tsx | 3 +-- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b604b4f..e48a69b 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,5 +1,5 @@ -import { getAuth } from "firebase/auth"; import { Link, NavLink } from "react-router-dom"; +import { auth } from "../firebase"; export const Header = () => { const navItems = [ @@ -8,7 +8,6 @@ export const Header = () => { { path: "/join", label: "Як долучитись" }, { path: "/rating", label: "Рейтинг" }, ]; - const auth = getAuth(); const user = auth.currentUser; return ( diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 256d1e0..e229e67 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -1,10 +1,10 @@ // Import the functions you need from the SDKs you need -import { initializeApp } from "firebase/app"; +import { initializeApp, type FirebaseOptions } from "firebase/app"; import { getAnalytics } from "firebase/analytics"; -import { getAuth, onAuthStateChanged } from "firebase/auth"; -import { initializeUI, requireDisplayName } from '@firebase-oss/ui-core'; +import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence } from "firebase/auth"; +import { initializeUI, providerPopupStrategy, requireDisplayName } from '@firebase-oss/ui-core'; -const firebaseConfig = { +const firebaseConfig: FirebaseOptions = { apiKey: "AIzaSyDYEFX52SBP1Z4H64li_BD9a-TnrB-8FQ8", authDomain: "tournament-project-9a31a.firebaseapp.com", projectId: "tournament-project-9a31a", @@ -17,12 +17,18 @@ const firebaseConfig = { const app = initializeApp(firebaseConfig); const analytics = getAnalytics(app); const auth = getAuth(app); +// TODO: implement redirect strategy const ui = initializeUI({ app, behaviors: [ requireDisplayName(), + providerPopupStrategy(), ], }); +await setPersistence(auth, browserLocalPersistence); +const google = new GoogleAuthProvider(); +google.addScope('profile'); +google.addScope('email'); onAuthStateChanged(auth, (user) => { if (user) { @@ -34,4 +40,4 @@ onAuthStateChanged(auth, (user) => { }); export default app; -export { analytics, auth, ui }; \ No newline at end of file +export { analytics, auth, ui, google }; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx index c918110..d484da0 100644 --- a/frontend/src/pages/Auth/SignIn.tsx +++ b/frontend/src/pages/Auth/SignIn.tsx @@ -1,5 +1,6 @@ -import { SignInAuthScreen } from "@firebase-oss/ui-react"; +import { GoogleSignInButton, SignInAuthScreen } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; +import { google } from "../../firebase"; const SignIn = () => { @@ -9,6 +10,7 @@ const SignIn = () => { }; return <> + Не маєте аккаунту? Створіть його! ; diff --git a/frontend/src/pages/Auth/SignOut.tsx b/frontend/src/pages/Auth/SignOut.tsx index c4c16ed..22af3a3 100644 --- a/frontend/src/pages/Auth/SignOut.tsx +++ b/frontend/src/pages/Auth/SignOut.tsx @@ -1,10 +1,10 @@ -import { getAuth, signOut } from "firebase/auth"; +import { signOut } from "firebase/auth"; import { useEffect } from "react"; import { Navigate } from "react-router-dom"; +import { auth } from "../../firebase"; const SignOut = () => { useEffect(() => { - const auth = getAuth(); signOut(auth).catch((error) => { console.log(error); }); diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx index 1d104d4..05a3580 100644 --- a/frontend/src/pages/Auth/SignUp.tsx +++ b/frontend/src/pages/Auth/SignUp.tsx @@ -1,5 +1,6 @@ -import { SignUpAuthScreen } from "@firebase-oss/ui-react"; +import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; +import { google } from "../../firebase"; const SignUp = () => { @@ -9,6 +10,7 @@ const SignUp = () => { }; return <> + Вже маєте аккаунт? Увійдіть у нього! ; diff --git a/frontend/src/pages/Home/components/TournamentSlider.tsx b/frontend/src/pages/Home/components/TournamentSlider.tsx index c1c505e..f27e5ff 100644 --- a/frontend/src/pages/Home/components/TournamentSlider.tsx +++ b/frontend/src/pages/Home/components/TournamentSlider.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback } from "react"; import { TournamentCard } from "../../../components/TournamentCard"; const CARDS_DATA = [ diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 22955c6..30e30b4 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,7 +1,6 @@ -import { getAuth } from "firebase/auth"; +import { auth } from "../../firebase"; export const Profile = () => { - const auth = getAuth(); const user = auth.currentUser; return

Вітаю, {user?.displayName}

; From 03dcec1b844243b7251f2ec8aa94954aec7bd5a8 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 12 Mar 2026 20:19:24 +0200 Subject: [PATCH 054/369] fix: no module named 'backend' --- backend/app/main.py | 2 +- backend/tests/routes/test_role_request.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index be19905..471fec6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ import app.routes.tournaments as tournaments import app.routes.users as users import app.routes.profile as profile -import backend.app.routes.role_requests as role_requests +import app.routes.role_requests as role_requests app = FastAPI() app.include_router(tournaments.router) diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index fe0f855..8af3ded 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -1,7 +1,7 @@ import pytest from app.main import app from app.dependencies import get_current_user -from backend.app.routes.role_requests import get_admin_user +from app.routes.role_requests import get_admin_user from app.models import RoleRequest from sqlalchemy import select From f73c036c33b34d7c6f0d72b39a2055ec0557ff29 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 14 Mar 2026 20:12:55 +0200 Subject: [PATCH 055/369] feat: save user in db on registration --- .gitignore | 3 +- backend/alembic/versions/3944cb4c1dfe_.py | 36 +++ backend/alembic/versions/6a4dcbf12865_.py | 36 +++ backend/alembic/versions/6bb77b2a4118_.py | 65 +++++ backend/alembic/versions/b09774274996_.py | 65 +++++ backend/alembic/versions/bf6e23f5cf9a_.py | 64 +++++ backend/app/config.py | 2 +- backend/app/firebase.py | 5 + backend/app/main.py | 17 ++ backend/app/models/user.py | 16 +- backend/app/routes/auth.py | 43 ++++ backend/app/routes/users.py | 11 +- backend/app/schemas/__init__.py | 3 +- backend/app/schemas/user.py | 13 + frontend/package-lock.json | 280 ++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/api/auth/register.ts | 15 ++ frontend/src/api/client.ts | 6 + frontend/src/hooks/useOAuthLogin.tsx | 14 ++ frontend/src/pages/Auth/SignIn.tsx | 5 +- frontend/src/pages/Auth/SignUp.tsx | 13 +- frontend/vite-env.d.ts | 11 + frontend/vite.config.ts | 1 + 23 files changed, 707 insertions(+), 18 deletions(-) create mode 100644 backend/alembic/versions/3944cb4c1dfe_.py create mode 100644 backend/alembic/versions/6a4dcbf12865_.py create mode 100644 backend/alembic/versions/6bb77b2a4118_.py create mode 100644 backend/alembic/versions/b09774274996_.py create mode 100644 backend/alembic/versions/bf6e23f5cf9a_.py create mode 100644 backend/app/firebase.py create mode 100644 backend/app/routes/auth.py create mode 100644 frontend/src/api/auth/register.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/hooks/useOAuthLogin.tsx create mode 100644 frontend/vite-env.d.ts diff --git a/.gitignore b/.gitignore index 6b6b3ed..0357677 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,5 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -*.db \ No newline at end of file +*.db +serviceAccountKey.json \ No newline at end of file diff --git a/backend/alembic/versions/3944cb4c1dfe_.py b/backend/alembic/versions/3944cb4c1dfe_.py new file mode 100644 index 0000000..6b734e0 --- /dev/null +++ b/backend/alembic/versions/3944cb4c1dfe_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 3944cb4c1dfe +Revises: 79c35894ec8e +Create Date: 2026-03-13 15:20:40.620748 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3944cb4c1dfe' +down_revision: Union[str, Sequence[str], None] = '79c35894ec8e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('password') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('password', sa.VARCHAR(), autoincrement=False, nullable=False)) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/6a4dcbf12865_.py b/backend/alembic/versions/6a4dcbf12865_.py new file mode 100644 index 0000000..a0e8641 --- /dev/null +++ b/backend/alembic/versions/6a4dcbf12865_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 6a4dcbf12865 +Revises: 3944cb4c1dfe +Create Date: 2026-03-14 08:57:34.972551 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6a4dcbf12865' +down_revision: Union[str, Sequence[str], None] = '3944cb4c1dfe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('firebase_uid', sa.String(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('firebase_uid') + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/6bb77b2a4118_.py b/backend/alembic/versions/6bb77b2a4118_.py new file mode 100644 index 0000000..117d330 --- /dev/null +++ b/backend/alembic/versions/6bb77b2a4118_.py @@ -0,0 +1,65 @@ +"""empty message + +Revision ID: 6bb77b2a4118 +Revises: bf6e23f5cf9a +Create Date: 2026-03-14 09:02:18.122578 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6bb77b2a4118' +down_revision: Union[str, Sequence[str], None] = 'bf6e23f5cf9a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('firebase_uid', sa.String(), nullable=False), + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('firebase_uid') + ) + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id']) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/b09774274996_.py b/backend/alembic/versions/b09774274996_.py new file mode 100644 index 0000000..99d12fc --- /dev/null +++ b/backend/alembic/versions/b09774274996_.py @@ -0,0 +1,65 @@ +"""empty message + +Revision ID: b09774274996 +Revises: 6bb77b2a4118 +Create Date: 2026-03-14 09:04:55.414283 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b09774274996' +down_revision: Union[str, Sequence[str], None] = '6bb77b2a4118' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('firebase_uid', sa.String(), nullable=False), + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('firebase_uid') + ) + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id']) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/bf6e23f5cf9a_.py b/backend/alembic/versions/bf6e23f5cf9a_.py new file mode 100644 index 0000000..ed9eef5 --- /dev/null +++ b/backend/alembic/versions/bf6e23f5cf9a_.py @@ -0,0 +1,64 @@ +"""empty message + +Revision ID: bf6e23f5cf9a +Revises: 6a4dcbf12865 +Create Date: 2026-03-14 09:00:33.301671 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bf6e23f5cf9a' +down_revision: Union[str, Sequence[str], None] = '6a4dcbf12865' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('firebase_uid', sa.String(), nullable=False), + sa.Column('full_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('firebase_uid', 'id'), + sa.UniqueConstraint('email') + ) + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id']) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/app/config.py b/backend/app/config.py index c591d6e..60c6741 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,7 +3,7 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file="../.env") + model_config = SettingsConfigDict(env_file="../.env", extra='ignore') SECRET_KEY: str = os.getenv("SECRET_KEY") SQLALCHEMY_DATABASE_URI: str = os.getenv("SQLALCHEMY_DATABASE_URI") ROLE_REQUEST_NOTIFICATION_MESSAGE: str = ''' diff --git a/backend/app/firebase.py b/backend/app/firebase.py new file mode 100644 index 0000000..cbf0870 --- /dev/null +++ b/backend/app/firebase.py @@ -0,0 +1,5 @@ +import firebase_admin +from pathlib import Path +cert = Path('app/serviceAccountKey.json').resolve() +cred = firebase_admin.credentials.Certificate(str(cert)) +firebase = firebase_admin.initialize_app(cred) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 471fec6..ac5b21e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,9 +3,26 @@ import app.routes.users as users import app.routes.profile as profile import app.routes.role_requests as role_requests +import app.routes.auth as auth +from fastapi.middleware.cors import CORSMiddleware app = FastAPI() + +origins = [ + "http://localhost", + "http://localhost:5173", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(tournaments.router) app.include_router(users.router) app.include_router(profile.router) app.include_router(role_requests.router) +app.include_router(auth.router) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0758298..916cc29 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -15,23 +15,27 @@ class User(Base, PKMixin): __tablename__ = "users" - + # Firbase user id + firebase_uid: Mapped[str] = mapped_column(unique=True) full_name: Mapped[str] = mapped_column(nullable=False) email: Mapped[str] = mapped_column(nullable=False, unique=True) - password: Mapped[str] = mapped_column(nullable=False) + # password: Mapped[str] = mapped_column(nullable=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) roles: Mapped[list["Role"]] = relationship( - secondary=user_roles, back_populates="users", lazy="selectin" + secondary=user_roles, back_populates="users", lazy="selectin", ) notifications: Mapped[list["Notification"]] = relationship( - back_populates="user", lazy="selectin" + back_populates="user", lazy="selectin", + cascade="all, delete-orphan", ) role_requests: Mapped[list["RoleRequest"]] = relationship( - back_populates="user", lazy="selectin" + back_populates="user", lazy="selectin", + cascade="all, delete-orphan", ) created_tournaments: Mapped[list["Tournament"]] = relationship( - back_populates="creator", lazy="selectin" + back_populates="creator", lazy="selectin", + cascade="all, delete-orphan", ) def __repr__(self): diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..48a9c8f --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas import UserIDToken, UserCreate +from app.dependencies import SessionDep +from app.models import User +from firebase_admin import auth +from firebase_admin.auth import ( + InvalidIdTokenError, + ExpiredIdTokenError, + RevokedIdTokenError, + CertificateFetchError, + UserDisabledError, +) +from app.firebase import firebase +from app.routes.users import get_user + +router = APIRouter(prefix='/auth', tags=['auth']) + +@router.post('/register/') +async def register(session: SessionDep, t: UserIDToken): + try: + token = auth.verify_id_token(t.id_token, firebase) + u = auth.get_user_by_email(token['email']) + try: + await get_user(u.uid, session) + await get_user(u.email, session) + except HTTPException: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='User exists!') + user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) + session.add(user) + await session.commit() + await session.refresh(user) + except ( + ValueError, + InvalidIdTokenError, + ExpiredIdTokenError, + RevokedIdTokenError, + CertificateFetchError, + UserDisabledError, + ) as e: + print(e) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Invalid id token!') + + \ No newline at end of file diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index 2cf81a4..8b0f69f 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -3,12 +3,17 @@ from app.schemas import UserPublic from app.models import User from app.dependencies import SessionDep -from sqlalchemy import select +from sqlalchemy import select, or_ router = APIRouter(prefix='/users', tags=['users']) -async def get_user(user_id: int, session: SessionDep): - statement = select(User).where(User.id==user_id) +async def get_user(identifier: str | int, session: SessionDep): + if isinstance(identifier, str): + statement = select(User).where(or_(User.firebase_uid==identifier, User.email==identifier)) + elif isinstance(identifier, int): + statement = select(User).where(User.id==identifier) + else: + raise ValueError('Wrong user identifier type') user = (await session.execute(statement)).scalar() if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 40c5e62..a4ced6d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,7 +2,6 @@ from .task import TaskModel from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel -from .user import UserModel from .role_request import RoleRequestPublic, RoleRequestCreate from .notification import NotificationPublic, NotificationCreate -from .user import UserModel, UserPublic, UserUpdate +from .user import UserModel, UserPublic, UserUpdate, UserCreate, UserLogin, UserIDToken diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 17eb95b..dbb01eb 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -12,6 +12,19 @@ def check_name(cls, value: str): if not value.strip(): raise ValueError("The name cannot be empty") return value + +class UserCreate(UserBase): + id: str = Field(..., description='Firebase user id') + email: EmailStr = Field(..., description='Email') + # password: str = Field(..., description='Password') + +class UserIDToken(BaseModel): + model_config = ConfigDict(from_attributes=True) + id_token: str = Field(..., description='Firebase id token used to identify users') + +class UserLogin(BaseModel): + email: EmailStr = Field(..., description='Email') + password: str = Field(..., description='Password') class UserUpdate(UserBase): full_name: str | None = None diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1c159f4..7e7f185 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@firebase-oss/ui-react": "^7.0.2-beta", "@lottiefiles/react-lottie-player": "^3.6.0", "@tailwindcss/vite": "^4.2.1", + "axios": "^1.13.6", "firebase": "^12.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2955,6 +2956,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3018,6 +3036,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3107,6 +3138,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3207,6 +3250,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3222,6 +3274,20 @@ "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -3248,6 +3314,51 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3634,6 +3745,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3648,6 +3795,15 @@ "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", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3667,6 +3823,43 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3693,6 +3886,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3709,6 +3914,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4229,6 +4473,36 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4461,6 +4735,12 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a2d3a3..f68d20a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@firebase-oss/ui-react": "^7.0.2-beta", "@lottiefiles/react-lottie-player": "^3.6.0", "@tailwindcss/vite": "^4.2.1", + "axios": "^1.13.6", "firebase": "^12.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/api/auth/register.ts b/frontend/src/api/auth/register.ts new file mode 100644 index 0000000..9437326 --- /dev/null +++ b/frontend/src/api/auth/register.ts @@ -0,0 +1,15 @@ +import type { User } from "firebase/auth"; +import apiClient from "../client"; + +const register = async (user: User, callback?: Function) => { + try { + await apiClient.post<{ token: string }>('/auth/register/', { + id_token: await user.getIdToken(), + }); + callback && callback(); + } catch (e) { + console.log(`Error occured ${e}`); + } +} + +export default register; \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..77cd2cb --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,6 @@ +import axios from "axios"; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_BACKEND_URL, +}); +export default apiClient; \ No newline at end of file diff --git a/frontend/src/hooks/useOAuthLogin.tsx b/frontend/src/hooks/useOAuthLogin.tsx new file mode 100644 index 0000000..157d42c --- /dev/null +++ b/frontend/src/hooks/useOAuthLogin.tsx @@ -0,0 +1,14 @@ +import type { UserCredential } from "firebase/auth"; +import { useNavigate } from "react-router-dom"; +import register from "../api/auth/register"; + +const useOAuthLogin = () => { + const navigate = useNavigate(); + return (cred: UserCredential) => { + register(cred.user); + // navigate is not passed in callback because if user alredy exists, the callback will not be executed + navigate('/'); + }; +} + +export default useOAuthLogin; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx index d484da0..cbc8fed 100644 --- a/frontend/src/pages/Auth/SignIn.tsx +++ b/frontend/src/pages/Auth/SignIn.tsx @@ -1,6 +1,8 @@ import { GoogleSignInButton, SignInAuthScreen } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; import { google } from "../../firebase"; +import type { UserCredential } from "firebase/auth"; +import useOAuthLogin from "../../hooks/useOAuthLogin"; const SignIn = () => { @@ -8,9 +10,10 @@ const SignIn = () => { const handleSignIn = () => { navigate('/'); }; + const OAuthLogin = useOAuthLogin(); return <> - + OAuthLogin(cred)} provider={google} /> Не маєте аккаунту? Створіть його! ; diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx index 05a3580..1c10fc5 100644 --- a/frontend/src/pages/Auth/SignUp.tsx +++ b/frontend/src/pages/Auth/SignUp.tsx @@ -1,16 +1,21 @@ import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; +import { type User, type UserCredential } from 'firebase/auth'; +import register from "../../api/auth/register"; import { google } from "../../firebase"; - +import useOAuthLogin from "../../hooks/useOAuthLogin"; const SignUp = () => { const navigate = useNavigate(); - const handleSignUp = () => { - navigate('/auth/sign-in/'); + const handleSignUp = (user: User) => { + register(user, () => { + navigate('/'); + }); }; + const OAuthLogin = useOAuthLogin(); return <> - + OAuthLogin(cred)} provider={google} /> Вже маєте аккаунт? Увійдіть у нього! ; diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 0000000..8a29bfe --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1,11 @@ +interface ViteTypeOptions { + strictImportMetaEnv: unknown; +} + +interface ImportMetaEnv { + readonly VITE_BACKEND_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c4069b7..4abdc0c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,4 +5,5 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + envDir: '../' }) From 1623f93756b6d59d2209f9393a53e3bdc393d091 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 14 Mar 2026 19:06:09 +0000 Subject: [PATCH 056/369] refactor: split TaskModel into Base, Update and Model schemas --- backend/app/routes/tasks.py | 8 ++++++++ backend/app/schemas/task.py | 28 +++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 backend/app/routes/tasks.py diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py new file mode 100644 index 0000000..9788bbe --- /dev/null +++ b/backend/app/routes/tasks.py @@ -0,0 +1,8 @@ +from fastapi.routing import APIRouter + +from app.schemas import + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.get("/", response_model=) \ No newline at end of file diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 13cd05b..f6b10b6 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -3,21 +3,35 @@ from pydantic import BaseModel, Field, field_validator, model_validator -class TaskModel(BaseModel): +class TaskBase(BaseModel): title: str = Field(..., min_length=3, description="Short name of the task") - description: str = Field( - description="A detailed description of what needs to be done" + description: str | None = Field( + None, description="A detailed description of what needs to be done" ) - start_time: datetime - end_time: datetime - tournament_id: int = Field(..., gt=0) - status_id: str = Field(..., gt=0) @field_validator("title") @classmethod def check_title(cls, value: str): + if not value.strip(): + raise ValueError("Title cannot be empty") return value.strip() + +class TaskUpdate(TaskBase): + title: str | None = Field(None, min_length=3) + description: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + tournament_id: int | None = Field(None, gt=0) + status_id: int | None = Field(None, gt=0) + + +class TaskModel(TaskBase): + start_time: datetime + end_time: datetime + tournament_id: int = Field(..., gt=0) + status_id: int = Field(..., gt=0) + @field_validator("start_time") @classmethod def start_not_past(cls, value: datetime): From ab03a921f5a26a45dd7f52b85711078082b849f7 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 14 Mar 2026 20:36:00 +0000 Subject: [PATCH 057/369] feat: adding a CRUD operation for Task --- backend/app/main.py | 4 +- backend/app/routes/tasks.py | 74 +++++++++++++++++++++++++++++-- backend/app/routes/tournaments.py | 4 +- backend/app/schemas/__init__.py | 2 +- backend/app/schemas/task.py | 14 +++++- 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 6c625ba..4ea96c7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,8 +2,10 @@ import app.routes.tournaments as tournaments import app.routes.users as users import app.routes.profile as profile +import app.routes.tasks as tasks app = FastAPI() app.include_router(tournaments.router) app.include_router(users.router) -app.include_router(profile.router) \ No newline at end of file +app.include_router(profile.router) +app.include_router(tasks.router) \ No newline at end of file diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index 9788bbe..d2d629e 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -1,8 +1,76 @@ +from fastapi import status, HTTPException from fastapi.routing import APIRouter - -from app.schemas import +from sqlalchemy import select, update +from app.dependencies import SessionDep +from app.models import Task +from app.schemas import TaskModel, TaskUpdate router = APIRouter(prefix="/tasks", tags=["tasks"]) -@router.get("/", response_model=) \ No newline at end of file +@router.get("/", response_model=list[TaskModel], status_code=status.HTTP_200_OK) +async def tasks(session: SessionDep): + statement = select(Task) + users = await session.execute(statement) + return users.scalars().all() + + +@router.get("/{task_id}", response_model=TaskModel, status_code=status.HTTP_200_OK) +async def task(task_id: int, session: SessionDep): + statement = select(Task).where(Task.id == task_id) + task = await session.execute(statement) + if not task.first(): + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"Task with ID {task_id} not found" + ) + return task.first() + + +@router.post("/", response_model=TaskModel, status_code=status.HTTP_201_CREATED) +async def create_task(task_data: TaskModel, session: SessionDep): + new_task = Task(**task_data.model_dump()) + + session.add(new_task) + await session.commit() + await session.refresh(new_task) + return new_task + + +@router.patch("/{task_id}", response_model=TaskModel, status_code=status.HTTP_200_OK) +async def update_task(task_id: int, task_data: TaskUpdate, session: SessionDep): + update_data = task_data.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="No fields provided for update") + + statement = ( + update(Task).where(Task.id == task_id).values(**update_data).returning(Task) + ) + + result = await session.execute(statement) + updated_task = result.scalar_one_or_none() + + if not updated_task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" + ) + + await session.commit() + return updated_task + + +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task(task_id: int, session: SessionDep): + statement = select(Task).where(Task.id == task_id) + result = await session.execute(statement) + + task = result.scalar_one_or_none() + + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Task with ID {task_id} not found", + ) + + await session.delete(task) + await session.commit() diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 49b9ee0..b15f37b 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, HTTPException, status -from app.schemas import TournamentModels -from app.models import Tournament +from app.schemas import TournamentModels, TeamModel +from app.models import Tournament, Team from app.dependencies import SessionDep from sqlalchemy import select diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 62b7928..116f1d1 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,5 +1,5 @@ from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel -from .task import TaskModel +from .task import TaskBase, TaskUpdate, TaskModel from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel from .user import UserModel, UserPublic, UserUpdate diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index f6b10b6..8f8b200 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from typing_extensions import Self from pydantic import BaseModel, Field, field_validator, model_validator @@ -35,10 +35,20 @@ class TaskModel(TaskBase): @field_validator("start_time") @classmethod def start_not_past(cls, value: datetime): - if value < datetime.now(): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + + if value < datetime.now(timezone.utc): raise ValueError("Task cannot start in the past") return value + @field_validator("end_time") + @classmethod + def end_make_aware(cls, value: datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value + @model_validator(mode="after") def check_time_logic(self) -> Self: if self.end_time <= self.start_time: From 6308e29f16c99a06a2ea7333a47e55339ef6e975 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 14 Mar 2026 22:27:58 +0000 Subject: [PATCH 058/369] refactor: replace duplicate select logic with get_task helper --- backend/app/routes/tasks.py | 42 ++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index d2d629e..beedfce 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -8,6 +8,14 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) +async def get_task(task_id: int, session: SessionDep) -> Task: + statement = select(Task).where(Task.id == task_id) + tournament = (await session.execute(statement)).scalar() + if not tournament: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found!") + return tournament + + @router.get("/", response_model=list[TaskModel], status_code=status.HTTP_200_OK) async def tasks(session: SessionDep): statement = select(Task) @@ -15,15 +23,9 @@ async def tasks(session: SessionDep): return users.scalars().all() -@router.get("/{task_id}", response_model=TaskModel, status_code=status.HTTP_200_OK) +@router.get("/{task_id}/", response_model=TaskModel, status_code=status.HTTP_200_OK) async def task(task_id: int, session: SessionDep): - statement = select(Task).where(Task.id == task_id) - task = await session.execute(statement) - if not task.first(): - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"Task with ID {task_id} not found" - ) - return task.first() + return await get_task(task_id, session) @router.post("/", response_model=TaskModel, status_code=status.HTTP_201_CREATED) @@ -36,18 +38,19 @@ async def create_task(task_data: TaskModel, session: SessionDep): return new_task -@router.patch("/{task_id}", response_model=TaskModel, status_code=status.HTTP_200_OK) +@router.patch("/{task_id}/", response_model=TaskModel, status_code=status.HTTP_200_OK) async def update_task(task_id: int, task_data: TaskUpdate, session: SessionDep): update_data = task_data.model_dump(exclude_unset=True) if not update_data: - raise HTTPException(status_code=400, detail="No fields provided for update") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update", + ) - statement = ( + result = await session.execute( update(Task).where(Task.id == task_id).values(**update_data).returning(Task) ) - - result = await session.execute(statement) updated_task = result.scalar_one_or_none() if not updated_task: @@ -59,18 +62,9 @@ async def update_task(task_id: int, task_data: TaskUpdate, session: SessionDep): return updated_task -@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{task_id}/", status_code=status.HTTP_204_NO_CONTENT) async def delete_task(task_id: int, session: SessionDep): - statement = select(Task).where(Task.id == task_id) - result = await session.execute(statement) - - task = result.scalar_one_or_none() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Task with ID {task_id} not found", - ) + task = await get_task(task_id, session) await session.delete(task) await session.commit() From 3631ee9d4b7a340da9028b9465864eea585b3984 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sun, 15 Mar 2026 20:52:41 +0000 Subject: [PATCH 059/369] refactor: rename tournament variable to task in get_task helper --- backend/app/routes/tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index beedfce..1871983 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -10,10 +10,10 @@ async def get_task(task_id: int, session: SessionDep) -> Task: statement = select(Task).where(Task.id == task_id) - tournament = (await session.execute(statement)).scalar() - if not tournament: + task = (await session.execute(statement)).scalar() + if not task: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found!") - return tournament + return task @router.get("/", response_model=list[TaskModel], status_code=status.HTTP_200_OK) From ba68bc28c1ac21cf011608937112e6a9ab49b4e2 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Mon, 16 Mar 2026 18:20:09 +0000 Subject: [PATCH 060/369] feat: sync models with dev --- backend/app/models/__init__.py | 8 ++++++-- backend/app/models/evaluation.py | 31 ++++++++++++++++++------------ backend/app/models/notification.py | 20 +++++++++++++++++++ backend/app/models/role.py | 5 ++++- backend/app/models/role_request.py | 20 +++++++++++++++++++ backend/app/models/submission.py | 20 +++++++++++++------ backend/app/models/task.py | 20 ++++++++++--------- backend/app/models/team.py | 11 +++++++++-- backend/app/models/tournament.py | 12 ++++++------ backend/app/models/user.py | 14 +++++++++++--- 10 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/role_request.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1becc32..752f0e8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,10 @@ from .base import Base +from .evaluation import SubmissionEvaluation, RequirementEvaluation +from .notification import Notification +from .role_request import RoleRequest from .role import Role +from .submission import Submission, SubmissionUrl, SubmissionUrlOption +from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption from .team import Team, TeamMember -from .user import User from .tournament import Tournament, TournamentStatusOption -from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption +from .user import User diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index ac3a805..909b28b 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -1,6 +1,8 @@ -from sqlalchemy import ForeignKey, Table, Column +from sqlalchemy import ForeignKey, Table, Column, UniqueConstraint from sqlalchemy.orm import mapped_column, Mapped, relationship + from .base import Base +from .mixin import PKMixin evaluation_requirements = Table( "evaluation_requirements", @@ -18,27 +20,32 @@ ) -class SubmissionEvaluation(Base): +class SubmissionEvaluation(Base, PKMixin): __tablename__ = "evaluations" + __table_args__ = (UniqueConstraint("submission_id", "jury_id"),) submission_id: Mapped[int] = mapped_column( - ForeignKey("submissions.team_id"), primary_key=True + ForeignKey("submissions.team_id"), nullable=False ) - jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + jury_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - submission: Mapped["Submission"] = relationship(back_populates="evaluations") - jury: Mapped["User"] = relationship() - evaluations: Mapped[list["RequirementEvaluation"]] = relationship( - back_populates="evaluation" + submission: Mapped["Submission"] = relationship( + back_populates="evaluations", lazy="selectin" + ) + jury: Mapped["User"] = relationship(lazy="selectin") + requirement_evaluations: Mapped[list["RequirementEvaluation"]] = relationship( + back_populates="evaluation", lazy="selectin" ) -class RequirementEvaluation(Base): +class RequirementEvaluation(Base, PKMixin): __tablename__ = "requirement_evaluations" evaluation_id: Mapped[int] = mapped_column( - ForeignKey("evaluations.id"), primary_key=True + ForeignKey("evaluations.id"), nullable=False ) evaluation: Mapped["SubmissionEvaluation"] = relationship( - back_populates="requirement_evaluations" + back_populates="requirement_evaluations", lazy="selectin" ) score: Mapped[int] - requirement: Mapped["TaskRequirementOption"] = relationship() + requirement: Mapped[list["TaskRequirementOption"]] = relationship( + secondary=evaluation_requirements, lazy="selectin" + ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..9769d7b --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey + +from .base import Base +from .mixin import PKMixin +from .user import User + + +class Notification(Base, PKMixin): + __tablename__ = "notifications" + + body: Mapped[str] = mapped_column(String(4096), unique=True) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + + user: Mapped["User"] = relationship( + back_populates="notifications", lazy="selectin" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 27b977d..7781eb2 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -11,7 +11,10 @@ class Role(Base, PKMixin): name: Mapped[str] = mapped_column(nullable=False, unique=True) users: Mapped[list["User"]] = relationship( - secondary=user_roles, back_populates="roles" + secondary=user_roles, back_populates="roles", lazy="selectin" + ) + requests: Mapped[list["RoleRequest"]] = relationship( + back_populates="role", lazy="selectin" ) def __repr__(self): diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py new file mode 100644 index 0000000..87c4a61 --- /dev/null +++ b/backend/app/models/role_request.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey + +from .base import Base +from .mixin import PKMixin + + +class RoleRequest(Base, PKMixin): + __tablename__ = "role_requests" + + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + + role: Mapped["Role"] = relationship(back_populates="requests", lazy="selectin") + user: Mapped["User"] = relationship(back_populates="role_requests", lazy="selectin") + + def __repr__(self): + return f"" +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 4380687..60aace2 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -7,17 +7,25 @@ class Submission(Base): __tablename__ = "submissions" team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True) - team: Mapped["Team"] = relationship(back_populates="submission", single_parent=True) - urls: Mapped[list["SubmissionUrl"]] = relationship(back_populates="submission") + + team: Mapped["Team"] = relationship(back_populates="submission", single_parent=True, lazy="selectin") + urls: Mapped[list["SubmissionUrl"]] = relationship(back_populates="submission", lazy="selectin") + evaluations: Mapped[list["SubmissionEvaluation"]] = relationship( + back_populates="submission" + ) class SubmissionUrl(Base): __tablename__ = "submission_urls" - submission_id: Mapped[int] = mapped_column(ForeignKey("submissions.team_id")) - url_id: Mapped[int] = mapped_column(ForeignKey("submission_url_options.name")) + submission_id: Mapped[int] = mapped_column( + ForeignKey("submissions.team_id"), primary_key=True + ) + url_id: Mapped[int] = mapped_column( + ForeignKey("submission_url_options.name"), primary_key=True + ) - submission: Mapped["Submission"] = relationship(back_populates="urls") - url: Mapped["SubmissionUrlOption"] = relationship() + submission: Mapped["Submission"] = relationship(back_populates="urls", lazy="selectin") + url: Mapped["SubmissionUrlOption"] = relationship(lazy="selectin") class SubmissionUrlOption(Base, OptionMixin): diff --git a/backend/app/models/task.py b/backend/app/models/task.py index c571869..7dc8ae1 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -26,20 +26,20 @@ class Task(Base, PKMixin): ForeignKey("tournaments.id", use_alter=True, name="fk_task_tournament") ) tournament: Mapped["Tournament"] = relationship( - "Tournament", back_populates="tasks", foreign_keys="Task.tournament_id" + "Tournament", back_populates="tasks", foreign_keys="Task.tournament_id", lazy="selectin" ) start_time: Mapped[datetime] end_time: Mapped[datetime] status_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) - status: Mapped["TaskStatusOption"] = relationship(back_populates="tasks") + status: Mapped["TaskStatusOption"] = relationship(back_populates="tasks", lazy="selectin") requirements: Mapped[list["TaskRequirementOption"]] = relationship( - secondary=task_requirements + secondary=task_requirements, lazy="selectin" ) class TaskStatusOption(Base, OptionMixin): __tablename__ = "task_statuses" - tasks: Mapped[list["Task"]] = relationship(back_populates="status") + tasks: Mapped[list["Task"]] = relationship(back_populates="status", lazy="selectin") class TaskRequirementOption(Base, OptionMixin): @@ -48,19 +48,21 @@ class TaskRequirementOption(Base, OptionMixin): ForeignKey("task_requirement_categories.name") ) category: Mapped["TaskRequirementCategory"] = relationship( - back_populates="task_requirement_options" + back_populates="task_requirement_options", lazy="selectin" ) class TaskRequirementCategory(Base, OptionMixin): __tablename__ = "task_requirement_categories" - main_id: Mapped[str] = mapped_column(ForeignKey("task_requirement_categories.name"), nullable=True) + main_id: Mapped[str] = mapped_column( + ForeignKey("task_requirement_categories.name"), nullable=True + ) sub_categories: Mapped[list["TaskRequirementCategory"]] = relationship( - back_populates="parent_category" + back_populates="parent_category", lazy="selectin" ) parent_category: Mapped["TaskRequirementCategory"] = relationship( - back_populates="sub_categories", remote_side="TaskRequirementCategory.name" + back_populates="sub_categories", remote_side="TaskRequirementCategory.name", lazy="selectin" ) task_requirement_options: Mapped[list["TaskRequirementOption"]] = relationship( - back_populates="category" + back_populates="category", lazy="selectin" ) diff --git a/backend/app/models/team.py b/backend/app/models/team.py index bf58c42..f3b0cb6 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -16,12 +16,19 @@ class Team(Base, PKMixin): ForeignKey("team_members.id", ondelete="CASCADE"), nullable=True ) - tournament: Mapped["Tournament"] = relationship(back_populates="teams") + tournament: Mapped["Tournament"] = relationship(back_populates="teams", lazy="selectin") members: Mapped[list["TeamMember"]] = relationship( back_populates="team", foreign_keys="TeamMember.team_id", cascade="all, delete-orphan", ) + captain: Mapped["TeamMember"] = relationship( + "TeamMember", foreign_keys="Team.captain_id", post_update=True + ) + + submission: Mapped["Submission"] = relationship( + back_populates="team", cascade="all, delete-orphan" + ) def __repr__(self): return f"" @@ -39,7 +46,7 @@ class TeamMember(Base, PKMixin): ) team: Mapped["Team"] = relationship( - back_populates="members", foreign_keys=[team_id] + back_populates="members", foreign_keys=[team_id], lazy="selectin" ) def __repr__(self): diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 92521da..1b14e67 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -20,17 +20,17 @@ class Tournament(Base, PKMixin): status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - teams: Mapped[list["Team"]] = relationship(back_populates="tournament") + teams: Mapped[list["Team"]] = relationship(back_populates="tournament", lazy="selectin") active_task: Mapped["Task"] = relationship( - "Task", foreign_keys="Tournament.active_task_id" + "Task", foreign_keys="Tournament.active_task_id", lazy="selectin" ) tasks: Mapped[list["Task"]] = relationship( - "Task", back_populates="tournament", foreign_keys="Task.tournament_id" + "Task", back_populates="tournament", foreign_keys="Task.tournament_id", lazy="selectin" ) status: Mapped["TournamentStatusOption"] = relationship( - back_populates="tournaments" + back_populates="tournaments", lazy="selectin" ) - creator: Mapped["User"] = relationship(back_populates="created_tournaments") + creator: Mapped["User"] = relationship(back_populates="created_tournaments", lazy="selectin") def __repr__(self): return f"" @@ -41,4 +41,4 @@ class TournamentStatusOption(Base, PKMixin): name: Mapped[str] = mapped_column(unique=True) - tournaments: Mapped[List["Tournament"]] = relationship(back_populates="status") + tournaments: Mapped[List["Tournament"]] = relationship(back_populates="status", lazy="selectin") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1f7d7f1..648c61f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -22,11 +22,19 @@ class User(Base, PKMixin): created_at: Mapped[datetime] = mapped_column(server_default=func.now()) roles: Mapped[list["Role"]] = relationship( - secondary=user_roles, back_populates="users" + secondary=user_roles, back_populates="users", lazy="selectin" ) + notifications: Mapped[list["Notification"]] = relationship( + back_populates="user", lazy="selectin" + ) + role_requests: Mapped[list["RoleRequest"]] = relationship( + back_populates="user", lazy="selectin" + ) + notifications: Mapped[list["Notification"]] = relationship(back_populates="user") + role_requests: Mapped[list["RoleRequest"]] = relationship(back_populates="user") created_tournaments: Mapped[list["Tournament"]] = relationship( - back_populates="creator" + back_populates="creator", lazy="selectin" ) def __repr__(self): - return f"" + return f"" From efd8861fb1681e0104dda3bcb2847e625b480fb4 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Mon, 16 Mar 2026 18:43:17 +0000 Subject: [PATCH 061/369] feat: add submission schemas --- backend/app/schemas/submission.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/app/schemas/submission.py diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py new file mode 100644 index 0000000..35d420f --- /dev/null +++ b/backend/app/schemas/submission.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field, ConfigDict + + +class SubmissionUrlOptionModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + display_name: str + + +class SubmissionUrlModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + url_id: str = Field(...) + url: SubmissionUrlOptionModel | None + + +class SubmissionModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + team_id: int = Field(...) + urls: list[SubmissionUrlModel] = Field(default_factory=list) From 542aba3372118b86240d4e176d935db06b01975b Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 17 Mar 2026 20:49:48 +0000 Subject: [PATCH 062/369] feat: add submission(get, get-all, delete) endpoints --- backend/alembic/versions/1afa2bfb3587_.py | 86 ------------------- backend/alembic/versions/b6b9a8bb78e1_.py | 68 --------------- ...3d5ce955b1_.py => f5889dcdd5c3_upgrade.py} | 78 +++++++++++++++-- backend/app/main.py | 4 +- backend/app/routes/submissions.py | 54 ++++++++++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/submission.py | 2 +- 7 files changed, 130 insertions(+), 163 deletions(-) delete mode 100644 backend/alembic/versions/1afa2bfb3587_.py delete mode 100644 backend/alembic/versions/b6b9a8bb78e1_.py rename backend/alembic/versions/{9c3d5ce955b1_.py => f5889dcdd5c3_upgrade.py} (65%) create mode 100644 backend/app/routes/submissions.py diff --git a/backend/alembic/versions/1afa2bfb3587_.py b/backend/alembic/versions/1afa2bfb3587_.py deleted file mode 100644 index 58d615a..0000000 --- a/backend/alembic/versions/1afa2bfb3587_.py +++ /dev/null @@ -1,86 +0,0 @@ -"""empty message - -Revision ID: 1afa2bfb3587 -Revises: b6b9a8bb78e1 -Create Date: 2026-03-02 19:58:36.054914 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '1afa2bfb3587' -down_revision: Union[str, Sequence[str], None] = 'b6b9a8bb78e1' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['main_id'], ['name']) - batch_op.drop_column('category_id') - - with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) - - with op.batch_alter_table('teams', schema=None) as batch_op: - batch_op.alter_column('captain_id', - existing_type=sa.INTEGER(), - nullable=True) - batch_op.create_unique_constraint(None, ['team_email']) - batch_op.create_unique_constraint(None, ['contact_info']) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'tournaments', ['tournament_id'], ['id']) - batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.alter_column('active_task_id', - existing_type=sa.INTEGER(), - nullable=True) - - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - batch_op.drop_column('cleated_at') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - batch_op.drop_column('created_at') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.alter_column('active_task_id', - existing_type=sa.INTEGER(), - nullable=False) - - with op.batch_alter_table('teams', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id']) - batch_op.drop_constraint(None, type_='unique') - batch_op.drop_constraint(None, type_='unique') - batch_op.alter_column('captain_id', - existing_type=sa.INTEGER(), - nullable=False) - - with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_statuses', ['category_id'], ['name']) - - with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: - batch_op.add_column(sa.Column('category_id', sa.VARCHAR(), nullable=False)) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/b6b9a8bb78e1_.py b/backend/alembic/versions/b6b9a8bb78e1_.py deleted file mode 100644 index f7bc1a3..0000000 --- a/backend/alembic/versions/b6b9a8bb78e1_.py +++ /dev/null @@ -1,68 +0,0 @@ -"""empty message - -Revision ID: b6b9a8bb78e1 -Revises: -Create Date: 2026-03-02 19:57:51.433308 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'b6b9a8bb78e1' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('task_requirement_categories', sa.Column('main_id', sa.String(), nullable=True)) - op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['main_id'], ['name']) - op.drop_column('task_requirement_categories', 'category_id') - op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_options', 'task_requirement_categories', ['category_id'], ['name']) - op.alter_column('teams', 'captain_id', - existing_type=sa.INTEGER(), - nullable=True) - op.create_unique_constraint(None, 'teams', ['team_email']) - op.create_unique_constraint(None, 'teams', ['contact_info']) - op.drop_constraint(None, 'teams', type_='foreignkey') - op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') - op.create_foreign_key(None, 'teams', 'tournaments', ['tournament_id'], ['id']) - op.alter_column('tournaments', 'active_task_id', - existing_type=sa.INTEGER(), - nullable=True) - op.add_column('users', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.drop_column('users', 'cleated_at') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.drop_column('users', 'created_at') - op.alter_column('tournaments', 'active_task_id', - existing_type=sa.INTEGER(), - nullable=False) - op.drop_constraint(None, 'teams', type_='foreignkey') - op.drop_constraint(None, 'teams', type_='foreignkey') - op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id']) - op.drop_constraint(None, 'teams', type_='unique') - op.drop_constraint(None, 'teams', type_='unique') - op.alter_column('teams', 'captain_id', - existing_type=sa.INTEGER(), - nullable=False) - op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_options', 'task_statuses', ['category_id'], ['name']) - op.add_column('task_requirement_categories', sa.Column('category_id', sa.VARCHAR(), nullable=False)) - op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['category_id'], ['name']) - op.drop_column('task_requirement_categories', 'main_id') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/9c3d5ce955b1_.py b/backend/alembic/versions/f5889dcdd5c3_upgrade.py similarity index 65% rename from backend/alembic/versions/9c3d5ce955b1_.py rename to backend/alembic/versions/f5889dcdd5c3_upgrade.py index 1cb0162..c21a08d 100644 --- a/backend/alembic/versions/9c3d5ce955b1_.py +++ b/backend/alembic/versions/f5889dcdd5c3_upgrade.py @@ -1,8 +1,8 @@ -"""empty message +"""Upgrade -Revision ID: 9c3d5ce955b1 -Revises: 1afa2bfb3587 -Create Date: 2026-03-02 20:04:34.646529 +Revision ID: f5889dcdd5c3 +Revises: +Create Date: 2026-03-16 21:38:28.577149 """ from typing import Sequence, Union @@ -12,8 +12,8 @@ # revision identifiers, used by Alembic. -revision: str = '9c3d5ce955b1' -down_revision: Union[str, Sequence[str], None] = '1afa2bfb3587' +revision: str = 'f5889dcdd5c3' +down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -27,6 +27,11 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) + op.create_table('submission_url_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) op.create_table('task_requirement_categories', sa.Column('main_id', sa.String(), nullable=True), sa.Column('name', sa.String(), nullable=False), @@ -61,11 +66,27 @@ def upgrade() -> None: sa.Column('full_name', sa.String(), nullable=False), sa.Column('email', sa.String(), nullable=False), sa.Column('password', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), sa.Column('id', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email') ) + op.create_table('notifications', + sa.Column('body', sa.String(length=4096), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('body') + ) + op.create_table('role_requests', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) op.create_table('task_requirement_options', sa.Column('category_id', sa.String(), nullable=False), sa.Column('name', sa.String(), nullable=False), @@ -129,22 +150,65 @@ def upgrade() -> None: sa.UniqueConstraint('name'), sa.UniqueConstraint('team_email') ) + op.create_table('submissions', + sa.Column('team_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ), + sa.PrimaryKeyConstraint('team_id') + ) + op.create_table('evaluations', + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('jury_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('submission_id', 'jury_id') + ) + op.create_table('submission_urls', + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('url_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), + sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ), + sa.PrimaryKeyConstraint('submission_id', 'url_id') + ) + op.create_table('requirement_evaluations', + sa.Column('evaluation_id', sa.Integer(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('evaluation_requirements', + sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False), + sa.Column('requirement_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id') + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('evaluation_requirements') + op.drop_table('requirement_evaluations') + op.drop_table('submission_urls') + op.drop_table('evaluations') + op.drop_table('submissions') op.drop_table('teams') op.drop_table('tournaments') op.drop_table('task_requirements') op.drop_table('user_roles') op.drop_table('tasks') op.drop_table('task_requirement_options') + op.drop_table('role_requests') + op.drop_table('notifications') op.drop_table('users') op.drop_table('tournament_status_options') op.drop_table('team_members') op.drop_table('task_statuses') op.drop_table('task_requirement_categories') + op.drop_table('submission_url_options') op.drop_table('roles') # ### end Alembic commands ### diff --git a/backend/app/main.py b/backend/app/main.py index 4ea96c7..6b4d775 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,9 +3,11 @@ import app.routes.users as users import app.routes.profile as profile import app.routes.tasks as tasks +import app.routes.submissions as submissions app = FastAPI() app.include_router(tournaments.router) app.include_router(users.router) app.include_router(profile.router) -app.include_router(tasks.router) \ No newline at end of file +app.include_router(tasks.router) +app.include_router(submissions.router) diff --git a/backend/app/routes/submissions.py b/backend/app/routes/submissions.py new file mode 100644 index 0000000..412c07d --- /dev/null +++ b/backend/app/routes/submissions.py @@ -0,0 +1,54 @@ +from fastapi import status, HTTPException +from fastapi.routing import APIRouter +from sqlalchemy import select, update +from sqlalchemy.orm import selectinload +from app.dependencies import SessionDep +from app.models import Submission, SubmissionUrl, SubmissionUrlOption, Team +from app.schemas import SubmissionModel, SubmissionUrlOptionModel, SubmissionUrlModel + +router = APIRouter(prefix="/submissions", tags=["submissions"]) + + +async def get_submission(team_id: int, session: SessionDep) -> Submission: + submission = await session.get(Submission, team_id) + + if submission is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found!" + ) + + return submission + + +@router.get("/", response_model=list[SubmissionModel], status_code=status.HTTP_200_OK) +async def submissions(session: SessionDep): + statement = select(Submission) + users = await session.execute(statement) + return users.scalars().all() + + +@router.get( + "/{team_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK +) +async def submission(team_id: int, session: SessionDep): + return await get_submission(team_id, session) + + +@router.post("/", response_model=SubmissionModel, status_code=status.HTTP_201_CREATED) +async def create_submission(submission_data: SubmissionModel, session: SessionDep): + pass + + +@router.patch( + "/{team_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK +) +async def update_submission(): + pass + + +@router.delete("/{team_id}/", status_code=status.HTTP_204_NO_CONTENT) +async def delete_submission(team_id: int, session: SessionDep): + submission = await get_submission(team_id, session) + + await session.delete(submission) + await session.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 116f1d1..a4619ad 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,4 +1,5 @@ from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel +from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionModel from .task import TaskBase, TaskUpdate, TaskModel from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py index 35d420f..60e53dd 100644 --- a/backend/app/schemas/submission.py +++ b/backend/app/schemas/submission.py @@ -10,7 +10,7 @@ class SubmissionUrlOptionModel(BaseModel): class SubmissionUrlModel(BaseModel): model_config = ConfigDict(from_attributes=True) - url_id: str = Field(...) + url_id: str = Field(..., description="ID of the URL option") url: SubmissionUrlOptionModel | None From 47fc037be11677250d1e53c83e89e3edc021cbfe Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 17 Mar 2026 20:52:35 +0000 Subject: [PATCH 063/369] refactor: remove unused dependencies from requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bcb84b5..20227c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ annotated-types==0.7.0 anyio==4.12.1 async-timeout==5.0.1 asyncpg==0.31.0 -backports.asyncio.runner==1.2.0 certifi==2026.1.4 click==8.3.1 dnspython==2.8.0 From d5d9d3d9645e592e321d1e1eb249ff6a8c85f0f6 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 17 Mar 2026 21:00:50 +0000 Subject: [PATCH 064/369] refactor: update Team and TeamMember schemas --- backend/app/schemas/team.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 97b5b53..213b16b 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -2,10 +2,25 @@ from pydantic_extra_types.phone_numbers import PhoneNumber -class TeamModel(BaseModel): +class TeamBase(BaseModel): name: str = Field(..., description="Name of the team") team_email: EmailStr = Field(..., description="Contact email") contact_info: PhoneNumber = Field(..., description="Phone number") + + @field_validator("team_email") + @classmethod + def normalize_email(cls, value: EmailStr): + return value.lower() + + +class TeamUpdate(TeamBase): + name: str | None = None + team_email: EmailStr | None = None + contact_info: str | None = None + captain_id: int | None = None + + +class TeamModel(TeamBase): tournament_id: int = Field(..., gt=0) captain_id: int = Field(..., gt=0) @@ -15,9 +30,20 @@ def normalize_email(cls, value: EmailStr): return value.lower() -class TeamMemberModel(BaseModel): +class TeamMemberBase(BaseModel): full_name: str = Field(..., min_length=3) email: EmailStr = Field(..., description="Contact email") telegram_username: str educational_institution: str + + +class TeamMemberUpdate(TeamMemberBase): + full_name: str | None = Field(None, min_length=3) + email: EmailStr | None = Field(None, description="Contact email") + telegram_username: str | None = None + educational_institution: str | None = None + team_id: int | None = Field(None, gt=0) + + +class TeamMemberModel(TeamMemberBase): team_id: int = Field(..., gt=0) From 8b5a788b830f26b36cdb7cbd9618dfb04b7540c8 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 17 Mar 2026 23:07:11 +0000 Subject: [PATCH 065/369] feat: add team endpoints --- backend/app/main.py | 2 + backend/app/routes/teams.py | 70 +++++++++++++++++++++++++++++++++ backend/app/schemas/__init__.py | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 backend/app/routes/teams.py diff --git a/backend/app/main.py b/backend/app/main.py index 6b4d775..e0b8442 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ import app.routes.profile as profile import app.routes.tasks as tasks import app.routes.submissions as submissions +import app.routes.teams as teams app = FastAPI() app.include_router(tournaments.router) @@ -11,3 +12,4 @@ app.include_router(profile.router) app.include_router(tasks.router) app.include_router(submissions.router) +app.include_router(teams.router) diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py new file mode 100644 index 0000000..1886413 --- /dev/null +++ b/backend/app/routes/teams.py @@ -0,0 +1,70 @@ +from fastapi import status, HTTPException +from fastapi.routing import APIRouter +from sqlalchemy import select, update +from app.dependencies import SessionDep +from app.models import Team +from app.schemas import TeamModel, TeamUpdate + +router = APIRouter(prefix="/teams", tags=["teams"]) + + +async def get_team(team_id: int, session: SessionDep) -> Team: + statement = select(Team).where(Team.id == team_id) + team = (await session.execute(statement)).scalar() + if not team: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Team not found!") + return team + + +@router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) +async def teams(session: SessionDep): + statement = select(Team) + teams = await session.execute(statement) + return teams.scalars().all() + + +@router.get("/{team_id}/", response_model=TeamModel, status_code=status.HTTP_200_OK) +async def team(team_id: int, session: SessionDep): + return await get_team(team_id, session) + + +@router.post("/", response_model=TeamModel, status_code=status.HTTP_201_CREATED) +async def create_team(team_data: TeamModel, session: SessionDep): + new_team = Team(**team_data.model_dump()) + + session.add(new_team) + await session.commit() + await session.refresh(new_team) + return new_team + + +@router.patch("/{team_id}/", response_model=TeamModel, status_code=status.HTTP_200_OK) +async def update_team(team_id: int, team_data: TeamUpdate, session: SessionDep): + update_data = team_data.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update", + ) + + result = await session.execute( + update(Team).where(Team.id == team_id).values(**update_data).returning(Team) + ) + updated_team = result.scalar_one_or_none() + + if not updated_team: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Team not found" + ) + + await session.commit() + return updated_team + + +@router.delete("/{team_id}/", status_code=status.HTTP_204_NO_CONTENT) +async def delete_team(team_id: int, session: SessionDep): + team = await get_team(team_id, session) + + await session.delete(team) + await session.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index a4619ad..9afbca2 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,6 +1,6 @@ from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionModel from .task import TaskBase, TaskUpdate, TaskModel -from .team import TeamModel, TeamMemberModel +from .team import TeamModel, TeamUpdate, TeamMemberModel, TeamMemberUpdate from .tournament import TournamentModels, TournamentStatusOptionModel from .user import UserModel, UserPublic, UserUpdate From 69876968e78902a684a7cfd6e020ab4f759a9920 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Wed, 18 Mar 2026 20:43:16 +0000 Subject: [PATCH 066/369] fix: fixes to error handling in endpoints, fixes to the contact_info field in TeamUpdate --- backend/app/routes/teams.py | 39 +++++++++++++++++++++++++++---------- backend/app/schemas/team.py | 2 +- requirements.txt | 3 +++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py index 1886413..16c6996 100644 --- a/backend/app/routes/teams.py +++ b/backend/app/routes/teams.py @@ -1,6 +1,7 @@ from fastapi import status, HTTPException from fastapi.routing import APIRouter from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError from app.dependencies import SessionDep from app.models import Team from app.schemas import TeamModel, TeamUpdate @@ -31,10 +32,18 @@ async def team(team_id: int, session: SessionDep): @router.post("/", response_model=TeamModel, status_code=status.HTTP_201_CREATED) async def create_team(team_data: TeamModel, session: SessionDep): new_team = Team(**team_data.model_dump()) - session.add(new_team) - await session.commit() - await session.refresh(new_team) + + try: + await session.commit() + await session.refresh(new_team) + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Team with this name, email or phone number already exists", + ) + return new_team @@ -48,17 +57,27 @@ async def update_team(team_id: int, team_data: TeamUpdate, session: SessionDep): detail="No fields provided for update", ) - result = await session.execute( - update(Team).where(Team.id == team_id).values(**update_data).returning(Team) - ) - updated_team = result.scalar_one_or_none() + try: + result = await session.execute( + update(Team).where(Team.id == team_id).values(**update_data).returning(Team) + ) + updated_team = result.scalar_one_or_none() + + if not updated_team: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Team not found" + ) - if not updated_team: + await session.commit() + await session.refresh(updated_team) + + except IntegrityError: + await session.rollback() raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Team not found" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Team with this name, email, or phone number already exists", ) - await session.commit() return updated_team diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 213b16b..73b7d33 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -16,7 +16,7 @@ def normalize_email(cls, value: EmailStr): class TeamUpdate(TeamBase): name: str | None = None team_email: EmailStr | None = None - contact_info: str | None = None + contact_info: PhoneNumber | None = None captain_id: int | None = None diff --git a/requirements.txt b/requirements.txt index 20227c1..589f1fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,8 @@ certifi==2026.1.4 click==8.3.1 dnspython==2.8.0 email-validator==2.3.0 +factory_boy==3.3.3 +Faker==40.5.1 fastapi==0.129.0 fastapi-cli==0.0.23 fastapi-cloud-cli==0.13.0 @@ -26,6 +28,7 @@ markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 packaging==26.0 +phonenumbers==9.0.26 pluggy==1.6.0 pydantic==2.12.5 pydantic-extra-types==2.11.0 From df10aa26fbb32e541ee4e1321c62da7ce57c588f Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Wed, 18 Mar 2026 22:23:51 +0000 Subject: [PATCH 067/369] fix: update helper --- backend/app/routes/submissions.py | 49 +++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/backend/app/routes/submissions.py b/backend/app/routes/submissions.py index 412c07d..02468e9 100644 --- a/backend/app/routes/submissions.py +++ b/backend/app/routes/submissions.py @@ -2,6 +2,8 @@ from fastapi.routing import APIRouter from sqlalchemy import select, update from sqlalchemy.orm import selectinload +from sqlalchemy.exc import IntegrityError + from app.dependencies import SessionDep from app.models import Submission, SubmissionUrl, SubmissionUrlOption, Team from app.schemas import SubmissionModel, SubmissionUrlOptionModel, SubmissionUrlModel @@ -10,7 +12,13 @@ async def get_submission(team_id: int, session: SessionDep) -> Submission: - submission = await session.get(Submission, team_id) + statement = ( + select(Submission) + .where(Submission.team_id == team_id) + .options(selectinload(Submission.urls).selectinload(SubmissionUrl.url)) + ) + result = await session.execute(statement) + submission = result.scalar_one_or_none() if submission is None: raise HTTPException( @@ -34,16 +42,39 @@ async def submission(team_id: int, session: SessionDep): return await get_submission(team_id, session) -@router.post("/", response_model=SubmissionModel, status_code=status.HTTP_201_CREATED) -async def create_submission(submission_data: SubmissionModel, session: SessionDep): - pass +# Not ready yet +# @router.post("/", response_model=SubmissionModel, status_code=status.HTTP_201_CREATED) +# async def create_submission(submission_data: SubmissionModel, session: SessionDep): +# team = await session.get(Team, submission_data.team_id) +# if not team: +# raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Team not found") -@router.patch( - "/{team_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK -) -async def update_submission(): - pass +# new_submission = Submission(team_id=submission_data.team_id) + +# for url_item in submission_data.urls: +# new_submission.urls.append(SubmissionUrl(url_id=url_item.url_id)) + +# session.add(new_submission) + +# try: +# await session.commit() +# await session.refresh(new_submission) +# except IntegrityError: +# await session.rollback() +# raise HTTPException( +# status_code=status.HTTP_400_BAD_REQUEST, +# detail="Submission for this team already exists", +# ) + +# return new_submission + + +# @router.patch( +# "/{team_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK +# ) +# async def update_submission(): +# pass @router.delete("/{team_id}/", status_code=status.HTTP_204_NO_CONTENT) From eea423ee50aed2131119095e5897f9d014559eb0 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 19 Mar 2026 22:23:34 +0200 Subject: [PATCH 068/369] feat: store user inside a redux slice --- .gitignore | 3 +- frontend/package-lock.json | 102 +++++++++++++++++++++++++ frontend/package.json | 2 + frontend/src/App.tsx | 30 +++++--- frontend/src/components/Header.tsx | 25 +++++- frontend/src/firebase.ts | 17 ++++- frontend/src/main.tsx | 6 +- frontend/src/pages/Auth/SignOut.tsx | 9 ++- frontend/src/pages/Home/Home.tsx | 100 ++++++++++++------------ frontend/src/pages/Profile/Profile.tsx | 4 +- frontend/src/slices/user.ts | 31 ++++++++ frontend/src/store.ts | 9 +++ 12 files changed, 260 insertions(+), 78 deletions(-) create mode 100644 frontend/src/slices/user.ts create mode 100644 frontend/src/store.ts diff --git a/.gitignore b/.gitignore index 0357677..c46b340 100644 --- a/.gitignore +++ b/.gitignore @@ -361,4 +361,5 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ *.db -serviceAccountKey.json \ No newline at end of file +serviceAccountKey.json +.vscode \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e7f185..1517ad2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@firebase-oss/ui-react": "^7.0.2-beta", "@lottiefiles/react-lottie-player": "^3.6.0", + "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", "axios": "^1.13.6", "firebase": "^12.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.1" }, @@ -1837,6 +1839,32 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2169,6 +2197,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -2580,6 +2620,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -3992,6 +4038,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4780,6 +4836,30 @@ "react": "^19.2.4" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4828,6 +4908,22 @@ "react-dom": ">=18" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4837,6 +4933,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f68d20a..ee9ef49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,11 +12,13 @@ "dependencies": { "@firebase-oss/ui-react": "^7.0.2-beta", "@lottiefiles/react-lottie-player": "^3.6.0", + "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", "axios": "^1.13.6", "firebase": "^12.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.1" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2cb532e..350c776 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,22 +7,28 @@ import { Page404 } from "./pages/Page404/Page404"; import SignIn from "./pages/Auth/SignIn"; import SignUp from "./pages/Auth/SignUp"; import SignOut from "./pages/Auth/SignOut"; +import { Header } from "./components/Header"; +import { Footer } from "./components/Footer"; export const App = () => { return ( - - } /> - } /> - } /> - - } /> - } /> - } /> - - } /> - } /> - +
+
+ + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + +
+
); }; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index e48a69b..84e77a9 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,5 +1,8 @@ import { Link, NavLink } from "react-router-dom"; import { auth } from "../firebase"; +import { Activity, useState } from "react"; +import { store, type RootState } from "../store"; +import { useSelector } from "react-redux"; export const Header = () => { const navItems = [ @@ -8,7 +11,9 @@ export const Header = () => { { path: "/join", label: "Як долучитись" }, { path: "/rating", label: "Рейтинг" }, ]; - const user = auth.currentUser; + const user = useSelector((s: RootState) => s.user); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const toggleMenu = () => setIsMenuOpen(!isMenuOpen); return (
@@ -43,9 +48,21 @@ export const Header = () => { ))} - {user ? - Профіль - : + {user ?
+ + Профіль + + + +
    +
  • + + Вийти + +
  • +
+
+
: Увійти }
diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index e229e67..c9c3cbb 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -1,8 +1,10 @@ -// Import the functions you need from the SDKs you need import { initializeApp, type FirebaseOptions } from "firebase/app"; import { getAnalytics } from "firebase/analytics"; import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence } from "firebase/auth"; import { initializeUI, providerPopupStrategy, requireDisplayName } from '@firebase-oss/ui-core'; +import { setUser } from "./slices/user"; +import { useDispatch } from "react-redux"; +import { store, type AppDispatch } from "./store"; const firebaseConfig: FirebaseOptions = { apiKey: "AIzaSyDYEFX52SBP1Z4H64li_BD9a-TnrB-8FQ8", @@ -32,9 +34,18 @@ google.addScope('email'); onAuthStateChanged(auth, (user) => { if (user) { - const uid = user.uid; - console.log(uid); + const userData = { + uid: user.uid, + email: user.email, + displayName: user.displayName, + photoURL: user.photoURL, + emailVerified: user.emailVerified, + isAnonymous: user.isAnonymous, + }; + store.dispatch(setUser(userData)); + console.log('User authenticated!'); } else { + store.dispatch(setUser(null)); console.log('Sign out!') } }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f43f99e..96438a3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,11 +4,15 @@ import "./index.css"; import { ui } from './firebase'; import { App } from "./App"; import { FirebaseUIProvider } from "@firebase-oss/ui-react"; +import { Provider } from "react-redux"; +import { store } from "./store"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/frontend/src/pages/Auth/SignOut.tsx b/frontend/src/pages/Auth/SignOut.tsx index 22af3a3..425e5aa 100644 --- a/frontend/src/pages/Auth/SignOut.tsx +++ b/frontend/src/pages/Auth/SignOut.tsx @@ -1,15 +1,18 @@ import { signOut } from "firebase/auth"; import { useEffect } from "react"; -import { Navigate } from "react-router-dom"; import { auth } from "../../firebase"; +import { useNavigate } from "react-router-dom"; const SignOut = () => { + const navigate = useNavigate(); useEffect(() => { - signOut(auth).catch((error) => { + signOut(auth).then(() => { + navigate('/', { replace: true }); + }).catch((error) => { console.log(error); }); }, []); - return ; + return
Loading...
; } export default SignOut; \ No newline at end of file diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 2b7e7fc..aeb74a1 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,62 +1,56 @@ -import { Header } from "../../components/Header"; -import { Footer } from "../../components/Footer"; import { Hero } from "../../components/Hero"; import { TournamentSlider } from "./components/TournamentSlider"; export const Home = () => { return ( <> -
-
- - Твори. -
- Дій. -
- Перемагай. - - } - description="Платформа для твоїх найсміливіших ідей. Від написання коду та дизайну до мистецтва й креативу — збирай команду, розкривай свій талант, ділися досвідом, набувай його і рухайся до вершини!" - badges={[ - { - text: "🔥 Прояви себе!", - className: - "bottom-[35%] left-[2vw] xl:left-[10vw] bg-dark-theme text-white -rotate-6", - }, - { - text: "💡 Твоя ідея змінить світ", - className: - "top-[15%] right-[2vw] xl:right-[8vw] bg-accent text-slate-900 rotate-3 text-[22px]", - }, - { - text: "🚀 Дій зараз", - className: - "bottom-[20%] right-[4vw] xl:right-[12vw] bg-pink-accent text-white -rotate-3", - }, - { - text: "🍕 Піца, код, перемога", - className: - "top-[25%] left-[5vw] xl:left-[12vw] bg-primary text-white rotate-6 border-2 border-white/20", - }, - { - text: "🤘 Будь собою!", - className: - "bottom-[50%] right-[1vw] xl:right-[5vw] bg-white text-dark-theme -rotate-12", - }, - ]} - mascot={{ - circularText: "★ ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ", - lottieSrc: "/hedgehog.json", - buttonText: "Долучитись", - buttonLink: "/register", - }} - /> - -
-
+ + Твори. +
+ Дій. +
+ Перемагай. + + } + description="Платформа для твоїх найсміливіших ідей. Від написання коду та дизайну до мистецтва й креативу — збирай команду, розкривай свій талант, ділися досвідом, набувай його і рухайся до вершини!" + badges={[ + { + text: "🔥 Прояви себе!", + className: + "bottom-[35%] left-[2vw] xl:left-[10vw] bg-dark-theme text-white -rotate-6", + }, + { + text: "💡 Твоя ідея змінить світ", + className: + "top-[15%] right-[2vw] xl:right-[8vw] bg-accent text-slate-900 rotate-3 text-[22px]", + }, + { + text: "🚀 Дій зараз", + className: + "bottom-[20%] right-[4vw] xl:right-[12vw] bg-pink-accent text-white -rotate-3", + }, + { + text: "🍕 Піца, код, перемога", + className: + "top-[25%] left-[5vw] xl:left-[12vw] bg-primary text-white rotate-6 border-2 border-white/20", + }, + { + text: "🤘 Будь собою!", + className: + "bottom-[50%] right-[1vw] xl:right-[5vw] bg-white text-dark-theme -rotate-12", + }, + ]} + mascot={{ + circularText: "★ ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ", + lottieSrc: "/hedgehog.json", + buttonText: "Долучитись", + buttonLink: "/register", + }} + /> + ); }; diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 30e30b4..0f2da55 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,7 +1,9 @@ +import { useSelector } from "react-redux"; import { auth } from "../../firebase"; +import type { RootState } from "../../store"; export const Profile = () => { - const user = auth.currentUser; + const user = useSelector((s: RootState) => s.user); return

Вітаю, {user?.displayName}

; }; diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts new file mode 100644 index 0000000..1d08f2c --- /dev/null +++ b/frontend/src/slices/user.ts @@ -0,0 +1,31 @@ +import { createSlice } from "@reduxjs/toolkit"; + +interface FirebaseUserData { + uid: string; + email: string | null; + displayName: string | null; + photoURL: string | null; + emailVerified: boolean; + isAnonymous: boolean; +} + +interface UserState { + user: FirebaseUserData | null; +}; + +const initialUserState: UserState = { + user: null, +} + +export const userSlice = createSlice({ + name: 'user', + initialState: initialUserState, + reducers: { + setUser: (state, action) => { + state.user = action.payload; + }, + + } +}) + +export const { setUser } = userSlice.actions; \ No newline at end of file diff --git a/frontend/src/store.ts b/frontend/src/store.ts new file mode 100644 index 0000000..13cb770 --- /dev/null +++ b/frontend/src/store.ts @@ -0,0 +1,9 @@ +import { configureStore } from '@reduxjs/toolkit' +import { userSlice } from './slices/user'; + +export const store = configureStore({ + reducer: userSlice.reducer, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file From 1e35055b1f74f75ce79fd5a83592800870130aad Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 20 Mar 2026 23:27:31 +0000 Subject: [PATCH 069/369] feat: add team_members CRUD endpoints --- backend/app/main.py | 2 + backend/app/routes/team_members.py | 102 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 backend/app/routes/team_members.py diff --git a/backend/app/main.py b/backend/app/main.py index e0b8442..b0a2ad4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ import app.routes.tasks as tasks import app.routes.submissions as submissions import app.routes.teams as teams +import app.routes.team_members as team_members app = FastAPI() app.include_router(tournaments.router) @@ -13,3 +14,4 @@ app.include_router(tasks.router) app.include_router(submissions.router) app.include_router(teams.router) +app.include_router(team_members.router) diff --git a/backend/app/routes/team_members.py b/backend/app/routes/team_members.py new file mode 100644 index 0000000..890c0b8 --- /dev/null +++ b/backend/app/routes/team_members.py @@ -0,0 +1,102 @@ +from fastapi import status, HTTPException +from fastapi.routing import APIRouter +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError + +from app.dependencies import SessionDep +from app.models import TeamMember +from app.schemas import TeamMemberModel, TeamMemberUpdate + +router = APIRouter(prefix="/team-members", tags=["team-members"]) + + +async def get_team_member(member_id: int, session: SessionDep) -> TeamMember: + statement = select(TeamMember).where(TeamMember.id == member_id) + member = (await session.execute(statement)).scalar() + + if not member: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Team member not found!", + ) + + return member + + +@router.get("/", response_model=list[TeamMemberModel]) +async def team_members(session: SessionDep): + statement = select(TeamMember) + result = await session.execute(statement) + return result.scalars().all() + + +@router.get("/{member_id}/", response_model=TeamMemberModel) +async def team_member(member_id: int, session: SessionDep): + return await get_team_member(member_id, session) + + +@router.post("/", response_model=TeamMemberModel, status_code=status.HTTP_201_CREATED) +async def create_team_member(member_data: TeamMemberModel, session: SessionDep): + new_member = TeamMember(**member_data.model_dump()) + session.add(new_member) + + try: + await session.commit() + await session.refresh(new_member) + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User with this email or telegram username already exists", + ) + + return new_member + + +@router.patch("/{member_id}/", response_model=TeamMemberModel) +async def update_team_member( + member_id: int, member_data: TeamMemberUpdate, session: SessionDep +): + update_data = member_data.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update", + ) + + try: + result = await session.execute( + update(TeamMember) + .where(TeamMember.id == member_id) + .values(**update_data) + .returning(TeamMember) + ) + + updated_member = result.scalar_one_or_none() + + if not updated_member: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Team member not found", + ) + + await session.commit() + await session.refresh(updated_member) + + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email or telegram username already exists", + ) + + return updated_member + + +@router.delete("/{member_id}/", status_code=status.HTTP_204_NO_CONTENT) +async def delete_team_member(member_id: int, session: SessionDep): + member = await get_team_member(member_id, session) + + await session.delete(member) + await session.commit() From 1adf9a20b28032c022d3b8387445ae191a3336d6 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 20 Mar 2026 23:29:48 +0000 Subject: [PATCH 070/369] refactor: add status code for team_members endpoint --- backend/app/routes/team_members.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/routes/team_members.py b/backend/app/routes/team_members.py index 890c0b8..b1461ad 100644 --- a/backend/app/routes/team_members.py +++ b/backend/app/routes/team_members.py @@ -23,14 +23,14 @@ async def get_team_member(member_id: int, session: SessionDep) -> TeamMember: return member -@router.get("/", response_model=list[TeamMemberModel]) +@router.get("/", response_model=list[TeamMemberModel], status_code=status.HTTP_200_OK) async def team_members(session: SessionDep): statement = select(TeamMember) result = await session.execute(statement) return result.scalars().all() -@router.get("/{member_id}/", response_model=TeamMemberModel) +@router.get("/{member_id}/", response_model=TeamMemberModel, status_code=status.HTTP_200_OK) async def team_member(member_id: int, session: SessionDep): return await get_team_member(member_id, session) @@ -53,7 +53,7 @@ async def create_team_member(member_data: TeamMemberModel, session: SessionDep): return new_member -@router.patch("/{member_id}/", response_model=TeamMemberModel) +@router.patch("/{member_id}/", response_model=TeamMemberModel, status_code=status.HTTP_200_OK) async def update_team_member( member_id: int, member_data: TeamMemberUpdate, session: SessionDep ): From 1f298689453d8c1bc98eb153cbdde68041f41e6e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Mon, 23 Mar 2026 19:46:52 +0200 Subject: [PATCH 071/369] feat(profile): add profile page. And add telegram link SFLU in main page --- frontend/src/components/Footer.tsx | 2 +- frontend/src/pages/Profile/Profile.css | 260 +++++++++++++++++++++++++ frontend/src/pages/Profile/Profile.tsx | 111 ++++++++++- 3 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/Profile/Profile.css diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index b54fdcc..24d1ff7 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -74,7 +74,7 @@ export const Footer = () => {
  • Telegram канал diff --git a/frontend/src/pages/Profile/Profile.css b/frontend/src/pages/Profile/Profile.css new file mode 100644 index 0000000..33f33c8 --- /dev/null +++ b/frontend/src/pages/Profile/Profile.css @@ -0,0 +1,260 @@ +/* Базові стилі та змінні дизайн-системи */ +:root { + /* Ваша палітра */ + --primary: #6366F1; + --accent: #FBBF24; + --background: #F8FAFC; + --success: #22C55E; + --deadline: #F97316; + + /* Допоміжні кольори для тексту та карток */ + --card-bg: #ffffff; + --text-main: #111827; + --text-muted: #6b7280; + --border-color: #e5e7eb; +} + +body { + background-color: var(--background); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; + padding: 0; +} + +.profile-container { + max-width: 1000px; + margin: 40px auto; + padding: 0 20px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.card { + background-color: var(--card-bg); + border-radius: 16px; + padding: 32px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + border: 1px solid var(--border-color); +} + +/* Верхня частина профілю */ +.profile-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.profile-info-wrapper { + display: flex; + gap: 24px; + align-items: center; +} + +.avatar-container { + width: 100px; + height: 100px; + background-color: #f3f4f6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 4px solid #f9fafb; + box-shadow: 0 0 0 1px var(--border-color); + color: #9ca3af; +} + +.avatar-icon { + width: 48px; + height: 48px; +} + +.profile-details { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.profile-name { + margin: 0; + font-size: 24px; + font-weight: 700; + color: var(--text-main); +} + +.profile-id { + margin: 0; + color: var(--text-muted); + font-size: 14px; +} + +.role-badge { + background-color: var(--primary); /* Використовуємо Primary з палітри */ + color: white; + padding: 6px 16px; + border-radius: 20px; + font-size: 14px; + font-weight: 500; +} + +.edit-btn { + background-color: var(--accent); /* Використовуємо Accent з палітри */ + color: #111827; + border: none; + padding: 10px 24px; + border-radius: 24px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.edit-btn:hover { + opacity: 0.9; +} + +.divider { + height: 1px; + background-color: var(--border-color); + margin: 32px 0; +} + +/* Способи зв'язку */ +.section-subtitle { + font-size: 12px; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.5px; + margin-top: 0; + margin-bottom: 16px; +} + +.contact-methods { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.contact-chip { + display: flex; + gap: 6px; + padding: 8px 16px; + background-color: #f3f4f6; + border-radius: 8px; + font-size: 14px; + border: 1px solid var(--border-color); +} + +.contact-chip a { + text-decoration: none; + color: #111827; + font-weight: 500; +} + +.contact-label { + color: var(--text-muted); +} + +.blue-chip { background-color: #eff6ff; border-color: #bfdbfe; } +.blue-chip a { color: #2563eb; } + +.purple-chip { background-color: #f5f3ff; border-color: #ddd6fe; } +.purple-chip a { color: #7c3aed; } + +/* Нижня сітка */ +.content-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.list-card { + padding: 24px; +} + +.card-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + margin-top: 0; + margin-bottom: 24px; + color: var(--text-main); +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.blue-dot { background-color: var(--primary); } /* Синхронізували з Primary */ +.yellow-dot { background-color: var(--accent); } /* Синхронізували з Accent */ + +.list-container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background-color: #f9fafb; + border: 1px solid var(--border-color); + border-radius: 12px; + font-size: 15px; + color: var(--text-main); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.list-item:hover { + background-color: #f3f4f6; +} + +.chevron-icon { + width: 20px; + height: 20px; + color: #d1d5db; +} + +.team-item { + justify-content: flex-start; + gap: 16px; +} + +.robot-icon-wrapper { + width: 32px; + height: 32px; + background-color: #e5e7eb; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #4b5563; +} + +.robot-icon { + width: 18px; + height: 18px; +} + +.team-name { + font-weight: 500; +} + +/* Адаптивність для мобільних пристроїв */ +@media (max-width: 768px) { + .profile-header-top { + flex-direction: column; + gap: 20px; + } + + .content-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index e0b5821..a645a44 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,3 +1,110 @@ -export const Profile = () => { - return

    Сторінка для профілю

    ; +import React from 'react'; +import './Profile.css'; + +const Profile = () => { + return ( +
    + {/* Головна картка профілю */} + + + {/* Нижня сітка з двома колонками */} +
    + + {/* Картка Турніри */} +
    +

    + Турніри +

    +
    + {[ + "Напишіть Ядро Лінукс", + "Напишіть свою мову програмування на рівні C++", + "Напишіть гру на JavaScript", + "Напишіть чат-бота на Python", + "Напишіть свою операційну систему" + ].map((item, index) => ( +
    + {item} + + + +
    + ))} +
    +
    + + {/* Картка Команди */} +
    +

    + Команди +

    +
    + {[ + "Шалені програмісти", + "Кодери мрії", + "Лінус Торвальдс" + ].map((item, index) => ( +
    +
    + + + + + + + + + +
    + {item} +
    + ))} +
    +
    + +
    +
    + ); }; + +export {Profile}; \ No newline at end of file From 5b3b7cc2b8d71f95278e884e7229da89c446463a Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 23 Mar 2026 20:46:45 +0200 Subject: [PATCH 072/369] refactor: move firebase credentials into .env file --- .env.example | 8 ++++++++ frontend/src/firebase.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index cf055f6..f42210e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,10 @@ SECRET_KEY="your secret key" SQLALCHEMY_DATABASE_URI="your database url" +VITE_BACKEND_URL= +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_STORAGE_BUCKET= +VITE_FIREBASE_MESSAGING_SENDER_ID= +VITE_FIREBASE_APP_ID= +VITE_FIREBASE_MEASUREMENT_ID= \ No newline at end of file diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index c9c3cbb..fdfae13 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -7,13 +7,13 @@ import { useDispatch } from "react-redux"; import { store, type AppDispatch } from "./store"; const firebaseConfig: FirebaseOptions = { - apiKey: "AIzaSyDYEFX52SBP1Z4H64li_BD9a-TnrB-8FQ8", - authDomain: "tournament-project-9a31a.firebaseapp.com", - projectId: "tournament-project-9a31a", - storageBucket: "tournament-project-9a31a.firebasestorage.app", - messagingSenderId: "92371798617", - appId: "1:92371798617:web:7819c49a21f61a3167bf48", - measurementId: "G-7BEXQ5VZHD" + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, + measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, }; const app = initializeApp(firebaseConfig); From 39052dfab5759175cb82849e80cb5d63f91b22af Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 23 Mar 2026 23:06:04 +0200 Subject: [PATCH 073/369] feat: implement get_current_user & refactor: delete profile route --- backend/app/dependencies/__init__.py | 4 +-- backend/app/dependencies/current_user.py | 42 +++++++++++++++++++++-- backend/app/main.py | 2 -- backend/app/routes/auth.py | 43 ------------------------ backend/app/routes/profile.py | 4 +++ frontend/src/api/auth/register.ts | 15 --------- frontend/src/api/deleteUser.ts | 19 +++++++++++ frontend/src/firebase.ts | 10 +----- frontend/src/hooks/useOAuthLogin.tsx | 14 -------- frontend/src/pages/Auth/SignIn.tsx | 6 +--- frontend/src/pages/Auth/SignUp.tsx | 12 ++----- frontend/src/pages/Profile/Profile.tsx | 11 ++++-- frontend/src/slices/user.ts | 12 ++----- 13 files changed, 80 insertions(+), 114 deletions(-) delete mode 100644 backend/app/routes/auth.py delete mode 100644 frontend/src/api/auth/register.ts create mode 100644 frontend/src/api/deleteUser.ts delete mode 100644 frontend/src/hooks/useOAuthLogin.tsx diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py index 4eb3da3..fa48ae1 100644 --- a/backend/app/dependencies/__init__.py +++ b/backend/app/dependencies/__init__.py @@ -1,2 +1,2 @@ -from .current_user import CurrentUserDep, get_current_user -from .session import SessionDep, get_session \ No newline at end of file +from .session import SessionDep, get_session +from .current_user import CurrentUserDep, get_current_user \ No newline at end of file diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py index 5ac6278..c3eaffb 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -1,8 +1,44 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Header, HTTPException, status from app.models import User +from .session import SessionDep +from firebase_admin import auth +from firebase_admin.auth import ( + InvalidIdTokenError, + ExpiredIdTokenError, + RevokedIdTokenError, + CertificateFetchError, + UserDisabledError, +) +from app.firebase import firebase +from app.routes.users import get_user -async def get_current_user(): - raise NotImplementedError('Authentication logic has to be implemented first!') +async def get_current_user( + session: SessionDep, + authorization: Annotated[str, Header()], +) -> User: + token = authorization.replace("Bearer ", "") + try: + token = auth.verify_id_token(token, firebase) + u = auth.get_user_by_email(token['email']) + try: + user = await get_user(u.uid, session) + except HTTPException: + user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) + session.add(user) + await session.commit() + await session.refresh(user) + except ( + ValueError, + InvalidIdTokenError, + ExpiredIdTokenError, + RevokedIdTokenError, + CertificateFetchError, + UserDisabledError, + ) as e: + print(e) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Invalid id token!') + return user + CurrentUserDep = Annotated[User, Depends(get_current_user)] \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index ac5b21e..6996152 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,6 @@ import app.routes.users as users import app.routes.profile as profile import app.routes.role_requests as role_requests -import app.routes.auth as auth from fastapi.middleware.cors import CORSMiddleware app = FastAPI() @@ -25,4 +24,3 @@ app.include_router(users.router) app.include_router(profile.router) app.include_router(role_requests.router) -app.include_router(auth.router) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py deleted file mode 100644 index 48a9c8f..0000000 --- a/backend/app/routes/auth.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi import APIRouter, HTTPException, status -from app.schemas import UserIDToken, UserCreate -from app.dependencies import SessionDep -from app.models import User -from firebase_admin import auth -from firebase_admin.auth import ( - InvalidIdTokenError, - ExpiredIdTokenError, - RevokedIdTokenError, - CertificateFetchError, - UserDisabledError, -) -from app.firebase import firebase -from app.routes.users import get_user - -router = APIRouter(prefix='/auth', tags=['auth']) - -@router.post('/register/') -async def register(session: SessionDep, t: UserIDToken): - try: - token = auth.verify_id_token(t.id_token, firebase) - u = auth.get_user_by_email(token['email']) - try: - await get_user(u.uid, session) - await get_user(u.email, session) - except HTTPException: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='User exists!') - user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) - session.add(user) - await session.commit() - await session.refresh(user) - except ( - ValueError, - InvalidIdTokenError, - ExpiredIdTokenError, - RevokedIdTokenError, - CertificateFetchError, - UserDisabledError, - ) as e: - print(e) - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Invalid id token!') - - \ No newline at end of file diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py index 0f6af75..71ca7b1 100644 --- a/backend/app/routes/profile.py +++ b/backend/app/routes/profile.py @@ -1,6 +1,8 @@ from fastapi import APIRouter, status from app.schemas import UserUpdate, UserPublic from app.dependencies import SessionDep, CurrentUserDep +import firebase_admin.auth as auth +from app.firebase import firebase router = APIRouter(prefix='/profile', tags=['profile']) @@ -15,4 +17,6 @@ async def edit_profile(session: SessionDep, current_user: CurrentUserDep, @router.delete('/', status_code=status.HTTP_204_NO_CONTENT) async def delete_profile(session: SessionDep, current_user: CurrentUserDep): await session.delete(current_user) + if auth.get_user(current_user.firebase_uid): + auth.delete_user(current_user.firebase_uid, firebase) await session.commit() \ No newline at end of file diff --git a/frontend/src/api/auth/register.ts b/frontend/src/api/auth/register.ts deleted file mode 100644 index 9437326..0000000 --- a/frontend/src/api/auth/register.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { User } from "firebase/auth"; -import apiClient from "../client"; - -const register = async (user: User, callback?: Function) => { - try { - await apiClient.post<{ token: string }>('/auth/register/', { - id_token: await user.getIdToken(), - }); - callback && callback(); - } catch (e) { - console.log(`Error occured ${e}`); - } -} - -export default register; \ No newline at end of file diff --git a/frontend/src/api/deleteUser.ts b/frontend/src/api/deleteUser.ts new file mode 100644 index 0000000..4870435 --- /dev/null +++ b/frontend/src/api/deleteUser.ts @@ -0,0 +1,19 @@ +import { auth } from "../firebase"; +import { setUser } from "../slices/user"; +import { store } from "../store"; +import apiClient from "./client"; + +const deleteUser = async (token: string) => { + try { + await apiClient.delete('/profile/', { + headers: { + Authorization: `Bearer ${token}` + }, + }); + store.dispatch(setUser(null)); + } catch (e) { + console.log(`Error occured ${e}`); + } +} + +export default deleteUser; \ No newline at end of file diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index fdfae13..765c06b 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -34,15 +34,7 @@ google.addScope('email'); onAuthStateChanged(auth, (user) => { if (user) { - const userData = { - uid: user.uid, - email: user.email, - displayName: user.displayName, - photoURL: user.photoURL, - emailVerified: user.emailVerified, - isAnonymous: user.isAnonymous, - }; - store.dispatch(setUser(userData)); + store.dispatch(setUser(user)); console.log('User authenticated!'); } else { store.dispatch(setUser(null)); diff --git a/frontend/src/hooks/useOAuthLogin.tsx b/frontend/src/hooks/useOAuthLogin.tsx deleted file mode 100644 index 157d42c..0000000 --- a/frontend/src/hooks/useOAuthLogin.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { UserCredential } from "firebase/auth"; -import { useNavigate } from "react-router-dom"; -import register from "../api/auth/register"; - -const useOAuthLogin = () => { - const navigate = useNavigate(); - return (cred: UserCredential) => { - register(cred.user); - // navigate is not passed in callback because if user alredy exists, the callback will not be executed - navigate('/'); - }; -} - -export default useOAuthLogin; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx index cbc8fed..62a688b 100644 --- a/frontend/src/pages/Auth/SignIn.tsx +++ b/frontend/src/pages/Auth/SignIn.tsx @@ -1,19 +1,15 @@ import { GoogleSignInButton, SignInAuthScreen } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; import { google } from "../../firebase"; -import type { UserCredential } from "firebase/auth"; -import useOAuthLogin from "../../hooks/useOAuthLogin"; - const SignIn = () => { const navigate = useNavigate(); const handleSignIn = () => { navigate('/'); }; - const OAuthLogin = useOAuthLogin(); return <> - OAuthLogin(cred)} provider={google} /> + Не маєте аккаунту? Створіть його! ; diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx index 1c10fc5..25dfc53 100644 --- a/frontend/src/pages/Auth/SignUp.tsx +++ b/frontend/src/pages/Auth/SignUp.tsx @@ -1,21 +1,15 @@ import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; -import { type User, type UserCredential } from 'firebase/auth'; -import register from "../../api/auth/register"; import { google } from "../../firebase"; -import useOAuthLogin from "../../hooks/useOAuthLogin"; const SignUp = () => { const navigate = useNavigate(); - const handleSignUp = (user: User) => { - register(user, () => { - navigate('/'); - }); + const handleSignUp = () => { + navigate('/'); }; - const OAuthLogin = useOAuthLogin(); return <> - OAuthLogin(cred)} provider={google} /> + Вже маєте аккаунт? Увійдіть у нього! ; diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 0f2da55..c4f0785 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,9 +1,16 @@ import { useSelector } from "react-redux"; -import { auth } from "../../firebase"; import type { RootState } from "../../store"; +import deleteUser from "../../api/deleteUser"; export const Profile = () => { const user = useSelector((s: RootState) => s.user); + const handleDeleteUser = async () => { + if (!user) return; + deleteUser(await user.getIdToken()); + }; - return

    Вітаю, {user?.displayName}

    ; + return
    +

    Вітаю, {user?.displayName}

    + +
    ; }; diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index 1d08f2c..93f61a5 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -1,16 +1,8 @@ import { createSlice } from "@reduxjs/toolkit"; - -interface FirebaseUserData { - uid: string; - email: string | null; - displayName: string | null; - photoURL: string | null; - emailVerified: boolean; - isAnonymous: boolean; -} +import type { User } from "firebase/auth"; interface UserState { - user: FirebaseUserData | null; + user: User | null; }; const initialUserState: UserState = { From 84d43ce44fa64e82d8f46ee1ff05680e75e559d7 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 23 Mar 2026 23:13:44 +0200 Subject: [PATCH 074/369] fix: user is not being updated in firebase causing a redirect when you visit sign in or sign up pages --- frontend/src/api/deleteUser.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/api/deleteUser.ts b/frontend/src/api/deleteUser.ts index 4870435..911f3e4 100644 --- a/frontend/src/api/deleteUser.ts +++ b/frontend/src/api/deleteUser.ts @@ -10,6 +10,7 @@ const deleteUser = async (token: string) => { Authorization: `Bearer ${token}` }, }); + await auth.updateCurrentUser(null); store.dispatch(setUser(null)); } catch (e) { console.log(`Error occured ${e}`); From 597d09372a05e3b5f24d20ef5b99f9a61d5951f4 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 23 Mar 2026 23:14:07 +0200 Subject: [PATCH 075/369] feat: implement protected route --- frontend/src/App.tsx | 7 ++++++- .../ProtectedRoute/ProtectedRoute.tsx | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ProtectedRoute/ProtectedRoute.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 350c776..968d92b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import SignUp from "./pages/Auth/SignUp"; import SignOut from "./pages/Auth/SignOut"; import { Header } from "./components/Header"; import { Footer } from "./components/Footer"; +import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; export const App = () => { return ( @@ -17,7 +18,11 @@ export const App = () => {
    } /> - } /> + + + + } /> } /> } /> diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..f6c27d2 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,17 @@ +import { type ReactNode } from "react"; +import { Navigate, Outlet } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "../../store"; + +interface ProtectedRouteProps { + children?: ReactNode; +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const user = useSelector((s: RootState) => s.user); + if (!user) return ; + + return children ? children : ; +} + +export default ProtectedRoute; \ No newline at end of file From 8293757a6d2d5789349eed11a0347b936af7ffb0 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Tue, 24 Mar 2026 09:17:44 +0200 Subject: [PATCH 076/369] refactor: store part of user instead of entire firebase user object in redux --- frontend/src/firebase.ts | 16 ++++++++++++---- frontend/src/pages/Profile/Profile.tsx | 2 +- frontend/src/slices/user.ts | 13 +++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 765c06b..042bb08 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -3,8 +3,7 @@ import { getAnalytics } from "firebase/analytics"; import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence } from "firebase/auth"; import { initializeUI, providerPopupStrategy, requireDisplayName } from '@firebase-oss/ui-core'; import { setUser } from "./slices/user"; -import { useDispatch } from "react-redux"; -import { store, type AppDispatch } from "./store"; +import { store } from "./store"; const firebaseConfig: FirebaseOptions = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY, @@ -32,9 +31,18 @@ const google = new GoogleAuthProvider(); google.addScope('profile'); google.addScope('email'); -onAuthStateChanged(auth, (user) => { +onAuthStateChanged(auth, async (user) => { if (user) { - store.dispatch(setUser(user)); + const userData = { + uid: user.uid, + email: user.email, + displayName: user.displayName, + photoURL: user.photoURL, + emailVerified: user.emailVerified, + isAnonymous: user.isAnonymous, + idToken: await user.getIdToken() + }; + store.dispatch(setUser(userData)); console.log('User authenticated!'); } else { store.dispatch(setUser(null)); diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index c4f0785..cf6188e 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -6,7 +6,7 @@ export const Profile = () => { const user = useSelector((s: RootState) => s.user); const handleDeleteUser = async () => { if (!user) return; - deleteUser(await user.getIdToken()); + deleteUser(user.idToken); }; return
    diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index 93f61a5..e6824d9 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -1,8 +1,17 @@ import { createSlice } from "@reduxjs/toolkit"; -import type { User } from "firebase/auth"; + +interface FirebaseUserData { + uid: string; + email: string | null; + displayName: string | null; + photoURL: string | null; + emailVerified: boolean; + isAnonymous: boolean; + idToken: string; +} interface UserState { - user: User | null; + user: FirebaseUserData | null; }; const initialUserState: UserState = { From e2d0ec6e88f81956e2bfb4791c982f98b137f255 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Tue, 24 Mar 2026 10:40:51 +0200 Subject: [PATCH 077/369] fix: displayName is null when registering using email and password --- frontend/src/pages/Auth/SignUp.tsx | 75 ++++++++++++++++++++++++++---- frontend/src/slices/user.ts | 7 ++- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx index 25dfc53..fd4b656 100644 --- a/frontend/src/pages/Auth/SignUp.tsx +++ b/frontend/src/pages/Auth/SignUp.tsx @@ -1,18 +1,73 @@ -import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-oss/ui-react"; +import { GoogleSignInButton, useUI } from "@firebase-oss/ui-react"; import { Link, useNavigate } from "react-router-dom"; -import { google } from "../../firebase"; +import { auth, google } from "../../firebase"; +import { useState, type ChangeEvent, type SubmitEvent } from "react"; +import { createUserWithEmailAndPassword, updateProfile } from "firebase/auth"; +import { setDisplayName } from "../../slices/user"; +import { store } from "../../store"; +import type { FirebaseError } from "firebase/app"; +import { getTranslation } from '@firebase-oss/ui-core'; const SignUp = () => { const navigate = useNavigate(); - const handleSignUp = () => { - navigate('/'); + // The reason we use our own form and not SignUpAuthScreen is + // the display name is set after the user creation using updateProfile + // Because of it the displayName is empty in redux when you register an account + // There is no way to track this event somehow, so we have to update the profile ourselves + const ui = useUI(); + const [formData, setFormData] = useState({ + displayName: '', + email: '', + password: '', + }); + const [formErrors, setFormErrors] = useState>({ + email: null, + password: null, + }) + const handleUpdate = (e: ChangeEvent) => { + const target = e.target; + setFormData({ + ...formData, + [target.name]: target.value, + }); + } + const handleSignUp = async (e: SubmitEvent) => { + e.preventDefault(); + if (!formData.displayName || !formData.email || !formData.password) return; + try { + const cred = await createUserWithEmailAndPassword(auth, formData.email, formData.password); + await updateProfile(cred.user, { + displayName: formData.displayName + }); + store.dispatch(setDisplayName(formData.displayName)); + navigate('/'); + } catch (e) { + const err = e as FirebaseError; + console.log(err.code); + // TODO?: Maybe we can find a way to handle the errors properly + if (err.code == 'auth/email-already-in-use') { + setFormErrors({ + ...formErrors, + email: getTranslation(ui, 'errors', 'emailAlreadyInUse') + }); + } else if (err.code == 'auth/password-does-not-meet-requirements') { + setFormErrors({ + ...formErrors, + password: getTranslation(ui, 'errors', 'weakPassword') + }); + } + } }; - return <> - - - Вже маєте аккаунт? Увійдіть у нього! - - ; + return
    + + + + {formErrors.email &&

    {formErrors.email}

    } + + {formErrors.password &&

    {formErrors.password}

    } + + Вже маєте аккаунт? Увійдіть у нього! + } export default SignUp; \ No newline at end of file diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index e6824d9..28cd876 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -25,8 +25,11 @@ export const userSlice = createSlice({ setUser: (state, action) => { state.user = action.payload; }, - + setDisplayName: (state, action) => { + if (!state.user) return; + state.user.displayName = action.payload; + } } }) -export const { setUser } = userSlice.actions; \ No newline at end of file +export const { setUser, setDisplayName } = userSlice.actions; \ No newline at end of file From c8cbc2975a37484b500100091c1c4ed3712e33cd Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Tue, 24 Mar 2026 10:42:19 +0200 Subject: [PATCH 078/369] chore: update requirements.txt --- requirements.txt | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 09f3356..af9443d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,21 +5,40 @@ annotated-types==0.7.0 anyio==4.12.1 async-timeout==5.0.1 asyncpg==0.31.0 -backports.asyncio.runner==1.2.0 +CacheControl==0.14.4 certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.5 click==8.3.1 +cryptography==46.0.5 dnspython==2.8.0 email-validator==2.3.0 exceptiongroup==1.3.1 +factory_boy==3.3.3 +Faker==40.8.0 fastapi==0.129.0 fastapi-cli==0.0.23 fastapi-cloud-cli==0.13.0 fastar==0.8.0 +firebase_admin==7.2.0 +google-api-core==2.30.0 +google-auth==2.49.1 +google-cloud-core==2.5.0 +google-cloud-firestore==2.25.0 +google-cloud-storage==3.9.0 +google-crc32c==1.8.0 +google-resumable-media==2.8.0 +googleapis-common-protos==1.73.0 greenlet==3.3.1 +grpcio==1.78.0 +grpcio-status==1.78.0 h11==0.16.0 +h2==4.3.0 +hpack==4.1.0 httpcore==1.0.9 httptools==0.7.1 httpx==0.28.1 +hyperframe==6.1.0 idna==3.11 iniconfig==2.3.0 Jinja2==3.1.6 @@ -27,14 +46,21 @@ Mako==1.3.10 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 +msgpack==1.1.2 packaging==26.0 phonenumbers==9.0.25 pluggy==1.6.0 +proto-plus==1.27.1 +protobuf==6.33.5 +pyasn1==0.6.2 +pyasn1_modules==0.4.2 +pycparser==3.0 pydantic==2.12.5 pydantic-extra-types==2.11.0 pydantic-settings==2.13.0 pydantic_core==2.41.5 Pygments==2.19.2 +PyJWT==2.12.0 pytest==9.0.2 pytest-asyncio==1.3.0 pytest-mock==3.15.1 @@ -42,6 +68,7 @@ pytest_async==0.1.1 python-dotenv==1.2.1 python-multipart==0.0.22 PyYAML==6.0.3 +requests==2.32.5 rich==14.3.2 rich-toolkit==0.19.4 rignore==0.7.6 From a1ca2831a206bd40a7b64116e974fa4df2bd93bf Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 19:32:32 +0200 Subject: [PATCH 079/369] refactor(auth): use HTTPBearer instead of authorization Header --- backend/app/dependencies/current_user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py index c3eaffb..1d2135f 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -1,5 +1,6 @@ from typing import Annotated -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer from app.models import User from .session import SessionDep from firebase_admin import auth @@ -15,11 +16,10 @@ async def get_current_user( session: SessionDep, - authorization: Annotated[str, Header()], + token: Annotated[HTTPBearer, Depends(HTTPBearer())] ) -> User: - token = authorization.replace("Bearer ", "") try: - token = auth.verify_id_token(token, firebase) + token = auth.verify_id_token(token.credentials, firebase) u = auth.get_user_by_email(token['email']) try: user = await get_user(u.uid, session) From dcb9cee456c52b2a93fda8e6d0caeeac4d4813fb Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 19:35:30 +0200 Subject: [PATCH 080/369] fix: add user loading state handler --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 3 ++- frontend/src/slices/user.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index f6c27d2..db4c67a 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -9,7 +9,8 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const user = useSelector((s: RootState) => s.user); - if (!user) return ; + if (user === undefined) return
    Loading...
    ; + if (user === null) return ; return children ? children : ; } diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index 28cd876..e87873e 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -11,11 +11,11 @@ interface FirebaseUserData { } interface UserState { - user: FirebaseUserData | null; + user: FirebaseUserData | null | undefined; }; const initialUserState: UserState = { - user: null, + user: undefined, } export const userSlice = createSlice({ From 494a2290322b584b5ba92028b5e5648432a6c783 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 19:39:18 +0200 Subject: [PATCH 081/369] refactor: stop storing idToken in redux as it expires in an hour --- frontend/src/api/deleteUser.ts | 6 ++++-- frontend/src/firebase.ts | 5 ++--- frontend/src/pages/Profile/Profile.tsx | 4 +++- frontend/src/slices/user.ts | 3 +-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/api/deleteUser.ts b/frontend/src/api/deleteUser.ts index 911f3e4..899803c 100644 --- a/frontend/src/api/deleteUser.ts +++ b/frontend/src/api/deleteUser.ts @@ -1,13 +1,15 @@ +import type { User } from "firebase/auth"; import { auth } from "../firebase"; import { setUser } from "../slices/user"; import { store } from "../store"; import apiClient from "./client"; -const deleteUser = async (token: string) => { +const deleteUser = async (user: User) => { try { + const token = await user.getIdToken(); await apiClient.delete('/profile/', { headers: { - Authorization: `Bearer ${token}` + Authorization: `Bearer ${token}`, }, }); await auth.updateCurrentUser(null); diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 042bb08..2bd4568 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -2,7 +2,7 @@ import { initializeApp, type FirebaseOptions } from "firebase/app"; import { getAnalytics } from "firebase/analytics"; import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence } from "firebase/auth"; import { initializeUI, providerPopupStrategy, requireDisplayName } from '@firebase-oss/ui-core'; -import { setUser } from "./slices/user"; +import { setUser, type FirebaseUserData } from "./slices/user"; import { store } from "./store"; const firebaseConfig: FirebaseOptions = { @@ -33,14 +33,13 @@ google.addScope('email'); onAuthStateChanged(auth, async (user) => { if (user) { - const userData = { + const userData: FirebaseUserData = { uid: user.uid, email: user.email, displayName: user.displayName, photoURL: user.photoURL, emailVerified: user.emailVerified, isAnonymous: user.isAnonymous, - idToken: await user.getIdToken() }; store.dispatch(setUser(userData)); console.log('User authenticated!'); diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index cf6188e..6eab8e5 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,12 +1,14 @@ import { useSelector } from "react-redux"; import type { RootState } from "../../store"; import deleteUser from "../../api/deleteUser"; +import { auth } from "../../firebase"; export const Profile = () => { const user = useSelector((s: RootState) => s.user); const handleDeleteUser = async () => { if (!user) return; - deleteUser(user.idToken); + if (!auth.currentUser) return; + deleteUser(auth.currentUser); }; return
    diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index e87873e..6689b48 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -1,13 +1,12 @@ import { createSlice } from "@reduxjs/toolkit"; -interface FirebaseUserData { +export interface FirebaseUserData { uid: string; email: string | null; displayName: string | null; photoURL: string | null; emailVerified: boolean; isAnonymous: boolean; - idToken: string; } interface UserState { From afad19569d5c53f59716fd9b90f654cf98ec0371 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 19:40:08 +0200 Subject: [PATCH 082/369] refactor: remove unused UserLogin and UserIDToken schemas --- backend/app/schemas/__init__.py | 2 +- backend/app/schemas/user.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index a4ced6d..d1b57d5 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -4,4 +4,4 @@ from .tournament import TournamentModels, TournamentStatusOptionModel from .role_request import RoleRequestPublic, RoleRequestCreate from .notification import NotificationPublic, NotificationCreate -from .user import UserModel, UserPublic, UserUpdate, UserCreate, UserLogin, UserIDToken +from .user import UserModel, UserPublic, UserUpdate, UserCreate diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index dbb01eb..be931ca 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -18,14 +18,6 @@ class UserCreate(UserBase): email: EmailStr = Field(..., description='Email') # password: str = Field(..., description='Password') -class UserIDToken(BaseModel): - model_config = ConfigDict(from_attributes=True) - id_token: str = Field(..., description='Firebase id token used to identify users') - -class UserLogin(BaseModel): - email: EmailStr = Field(..., description='Email') - password: str = Field(..., description='Password') - class UserUpdate(UserBase): full_name: str | None = None From ec56fbeab6fe5c4d59535f1542fd5df5df636614 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 21:05:06 +0200 Subject: [PATCH 083/369] test(bug fix): tests failed because no client, user and role fixture existed --- backend/app/models/user.py | 1 - backend/app/schemas/user.py | 2 - backend/tests/conftest.py | 15 ++++++++ backend/tests/factories.py | 3 +- backend/tests/routes/test_profile.py | 14 +++++-- backend/tests/routes/test_role_request.py | 46 ++++++++++++++++------- backend/tests/test_models.py | 5 --- backend/tests/test_util.py | 5 ++- 8 files changed, 63 insertions(+), 28 deletions(-) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 823b760..83a7d84 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -19,7 +19,6 @@ class User(Base, PKMixin): firebase_uid: Mapped[str] = mapped_column(unique=True) full_name: Mapped[str] = mapped_column(nullable=False) email: Mapped[str] = mapped_column(nullable=False, unique=True) - # password: Mapped[str] = mapped_column(nullable=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) roles: Mapped[list["Role"]] = relationship( diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index be931ca..5dc3415 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -16,7 +16,6 @@ def check_name(cls, value: str): class UserCreate(UserBase): id: str = Field(..., description='Firebase user id') email: EmailStr = Field(..., description='Email') - # password: str = Field(..., description='Password') class UserUpdate(UserBase): full_name: str | None = None @@ -27,7 +26,6 @@ class UserPublic(UserBase): class UserModel(UserBase): email: EmailStr = Field(..., description="User email") - password: str = Field(..., description="User password") @field_validator("email") @classmethod diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9a87185..363734a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,10 @@ import pytest +from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from app.models import Base +from app.main import app +from app.db import get_session TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -37,3 +40,15 @@ async def _create(factory_class, **kwargs): return obj return _create + + +@pytest.fixture +async def client(db_session): + async def override_get_session(): + yield db_session + + app.dependency_overrides[get_session] = override_get_session + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() \ No newline at end of file diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 806afa2..a597726 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -34,10 +34,9 @@ class UserFactory(BaseFactory): class Meta: model = User + firebase_uid = Faker('uuid4') full_name = Faker("name") email = factory.Sequence(lambda n: f"user{n}@example.com") - password = "very_strong_password" - class RoleFactory(BaseFactory): class Meta: diff --git a/backend/tests/routes/test_profile.py b/backend/tests/routes/test_profile.py index eee04fd..2d13565 100644 --- a/backend/tests/routes/test_profile.py +++ b/backend/tests/routes/test_profile.py @@ -1,22 +1,28 @@ import pytest +from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.main import app from app.dependencies import get_current_user from app.models import User +from tests.factories import UserFactory -async def test_update_profile(user, client): +async def test_update_profile(create, client): + user = await create(UserFactory) full_name = 'John Doe' app.dependency_overrides[get_current_user] = lambda: user assert user.full_name != full_name - resp = client.patch('/profile/', json={ + resp = await client.patch('/profile/', json={ 'full_name': full_name }) assert resp.json()['full_name'] == full_name app.dependency_overrides.pop(get_current_user) -async def test_delete_profile(user, client, db_session): +async def test_delete_profile(create, client, db_session, mocker): + user = await create(UserFactory) assert await db_session.get(User, user.id) is not None + mocker.patch("app.routes.profile.auth.get_user", return_value=False) app.dependency_overrides[get_current_user] = lambda: user - resp = client.delete('/profile/') + resp = await client.delete('/profile/') assert resp.status_code == 204 assert await db_session.get(User, user.id) is None app.dependency_overrides.pop(get_current_user) \ No newline at end of file diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index 8af3ded..9cd6395 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -1,16 +1,20 @@ import pytest +from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.main import app from app.dependencies import get_current_user from app.routes.role_requests import get_admin_user -from app.models import RoleRequest -from sqlalchemy import select +from app.models import RoleRequest, User +from tests.factories import UserFactory, RoleFactory @pytest.mark.asyncio -async def test_role_request_and_approval(user, role, client, db_session): +async def test_role_request_and_approval(create, client, db_session): + user = await create(UserFactory) + role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_admin_user] = lambda: user assert len(user.roles) == 0 - resp = client.post('/role-requests/', json={ + resp = await client.post('/role-requests/', json={ 'role_id': role.id, 'user_id': user.id, }) @@ -20,7 +24,7 @@ async def test_role_request_and_approval(user, role, client, db_session): req_id = resp.json()['id'] s = select(RoleRequest) assert len((await db_session.execute(s)).scalars().all()) == 1 - resp = client.post(f'/role-requests/{req_id}/approve/') + resp = await client.post(f'/role-requests/{req_id}/approve/') assert resp.status_code == 200 assert len(resp.json()['roles']) == 1 assert len((await db_session.execute(s)).scalars().all()) == 0 @@ -28,12 +32,16 @@ async def test_role_request_and_approval(user, role, client, db_session): app.dependency_overrides.pop(get_admin_user) @pytest.mark.asyncio -async def test_create_role_request_exists(user, role, client, db_session): +async def test_create_role_request_exists(create, client, db_session): + user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() + role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user r = RoleRequest(role_id=role.id, user_id=user.id) db_session.add(r) await db_session.commit() - resp = client.post('/role-requests/', json={ + resp = await client.post('/role-requests/', json={ 'role_id': role.id, 'user_id': user.id, }) @@ -45,13 +53,17 @@ async def test_create_role_request_exists(user, role, client, db_session): app.dependency_overrides.pop(get_current_user) @pytest.mark.asyncio -async def test_role_request_disapproval(user, role, client, db_session): +async def test_role_request_disapproval(create, client, db_session): + user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() + role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user r = RoleRequest(role_id=role.id, user_id=user.id) db_session.add(r) await db_session.commit() await db_session.refresh(r) - resp = client.post(f'/role-requests/{r.id}/disapprove/') + resp = await client.post(f'/role-requests/{r.id}/disapprove/') assert resp.status_code == 200 assert len(user.roles) == 0 s = select(RoleRequest) @@ -59,26 +71,34 @@ async def test_role_request_disapproval(user, role, client, db_session): app.dependency_overrides.pop(get_current_user) @pytest.mark.asyncio -async def test_get_role_request(user, role, client, db_session): +async def test_get_role_request(create, client, db_session): + user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() + role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user r = RoleRequest(role_id=role.id, user_id=user.id) db_session.add(r) await db_session.commit() await db_session.refresh(r) - resp = client.get(f'/role-requests/{r.id}/') + resp = await client.get(f'/role-requests/{r.id}/') assert resp.status_code == 200 assert resp.json()['role_id'] == r.role_id assert resp.json()['user_id'] == r.user_id app.dependency_overrides.pop(get_current_user) @pytest.mark.asyncio -async def test_delete_role_request(user, role, client, db_session): +async def test_delete_role_request(create, client, db_session): + user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() + role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user r = RoleRequest(role_id=role.id, user_id=user.id) db_session.add(r) await db_session.commit() await db_session.refresh(r) - resp = client.delete(f'/role-requests/{r.id}/') + resp = await client.delete(f'/role-requests/{r.id}/') assert resp.status_code == 204 assert not (await db_session.execute(select(RoleRequest))).scalar() app.dependency_overrides.pop(get_current_user) \ No newline at end of file diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index b60c00e..5476c89 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -63,11 +63,6 @@ async def test_create_user_duplicate_email(db_session, create): await db_session.rollback() -async def test_create_user_without_password(create): - with pytest.raises(IntegrityError): - await create(UserFactory, password=None) - - # ROLE TESTS async def test_create_role(create): role = await create(RoleFactory, name="jury") diff --git a/backend/tests/test_util.py b/backend/tests/test_util.py index 05a26e7..0b4093c 100644 --- a/backend/tests/test_util.py +++ b/backend/tests/test_util.py @@ -1,9 +1,12 @@ import pytest from app.util import send_notification from app.schemas import NotificationCreate +from .factories import UserFactory, RoleFactory from app.config import settings @pytest.mark.asyncio -async def test_send_role_request_notification(db_session, user, role): +async def test_send_role_request_notification(db_session, create): + user = await create(UserFactory) + role = await create(RoleFactory) notification = NotificationCreate(body=settings.ROLE_REQUEST_NOTIFICATION_MESSAGE, user_id=user.id) await send_notification(notification, db_session, user=user.full_name, role=role.name) \ No newline at end of file From 4e96587df24167df21484fe7850c51e6c2de1f06 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 21:08:46 +0200 Subject: [PATCH 084/369] test(bug fix): sqlalchemy.exc.MissingGreenlet caused two tests to fail --- backend/tests/routes/test_profile.py | 6 +++++- backend/tests/routes/test_role_request.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/tests/routes/test_profile.py b/backend/tests/routes/test_profile.py index 2d13565..0132be2 100644 --- a/backend/tests/routes/test_profile.py +++ b/backend/tests/routes/test_profile.py @@ -6,8 +6,10 @@ from app.models import User from tests.factories import UserFactory -async def test_update_profile(create, client): +async def test_update_profile(create, client, db_session): user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() full_name = 'John Doe' app.dependency_overrides[get_current_user] = lambda: user assert user.full_name != full_name @@ -19,6 +21,8 @@ async def test_update_profile(create, client): async def test_delete_profile(create, client, db_session, mocker): user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() assert await db_session.get(User, user.id) is not None mocker.patch("app.routes.profile.auth.get_user", return_value=False) app.dependency_overrides[get_current_user] = lambda: user diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index 9cd6395..f5e1f9e 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -10,6 +10,8 @@ @pytest.mark.asyncio async def test_role_request_and_approval(create, client, db_session): user = await create(UserFactory) + stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) + user = (await db_session.execute(stmt)).unique().scalar_one() role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_admin_user] = lambda: user From 4f49c35a43b1072918808cc06843bd6d7c2ad3c2 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 21:11:22 +0200 Subject: [PATCH 085/369] fix: Profile page was not loading because imports were absent --- frontend/src/pages/Profile/Profile.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 8713236..a74a166 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,5 +1,8 @@ -import React from 'react'; import './Profile.css'; +import { useSelector } from 'react-redux'; +import { auth } from '../../firebase'; +import deleteUser from '../../api/deleteUser'; +import type { RootState } from '../../store'; const Profile = () => { const user = useSelector((s: RootState) => s.user); @@ -27,7 +30,7 @@ const Profile = () => {
    - +
    @@ -57,7 +60,7 @@ const Profile = () => { {/* Нижня сітка з двома колонками */}
    - + {/* Картка Турніри */}

    @@ -115,4 +118,4 @@ const Profile = () => { ); }; -export {Profile}; \ No newline at end of file +export { Profile }; \ No newline at end of file From 285f5bab275b1ac957933e6c3e6b90a798c5bd4a Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 25 Mar 2026 21:13:01 +0200 Subject: [PATCH 086/369] refactor: use real username instead of hard-coded one --- frontend/src/pages/Profile/Profile.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index a74a166..d686480 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -11,6 +11,7 @@ const Profile = () => { if (!auth.currentUser) return; deleteUser(auth.currentUser); }; + if (!user) return
    Loading...
    return (
    @@ -25,7 +26,7 @@ const Profile = () => {
    -

    Hacker777

    +

    {user?.displayName}

    Роль: Користувач

    From 9bec2349bf35f7bdb84982b6d3b35b20e9042534 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 26 Mar 2026 07:50:13 +0200 Subject: [PATCH 087/369] chore: update requirements.txt --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/requirements.txt b/requirements.txt index 4d3c715..af9443d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiosqlite==0.22.1 +alembic==1.18.4 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 @@ -41,11 +42,13 @@ hyperframe==6.1.0 idna==3.11 iniconfig==2.3.0 Jinja2==3.1.6 +Mako==1.3.10 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 msgpack==1.1.2 packaging==26.0 +phonenumbers==9.0.25 pluggy==1.6.0 proto-plus==1.27.1 protobuf==6.33.5 @@ -60,6 +63,8 @@ Pygments==2.19.2 PyJWT==2.12.0 pytest==9.0.2 pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +pytest_async==0.1.1 python-dotenv==1.2.1 python-multipart==0.0.22 PyYAML==6.0.3 @@ -71,6 +76,7 @@ sentry-sdk==2.53.0 shellingham==1.5.4 SQLAlchemy==2.0.46 starlette==0.52.1 +tomli==2.4.0 typer==0.24.0 typing-inspection==0.4.2 typing_extensions==4.15.0 From b08cc963731069c28abf966db6586aef2d1dd6cd Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 26 Mar 2026 08:22:08 +0200 Subject: [PATCH 088/369] refactor: restructe api folder --- frontend/src/api/{ => requests}/deleteUser.ts | 14 +++++------ frontend/src/api/requests/index.ts | 1 + frontend/src/pages/Profile/Profile.tsx | 2 +- frontend/tsconfig.app.json | 24 ++++++++++++++----- frontend/vite.config.ts | 8 ++++++- 5 files changed, 33 insertions(+), 16 deletions(-) rename frontend/src/api/{ => requests}/deleteUser.ts (64%) create mode 100644 frontend/src/api/requests/index.ts diff --git a/frontend/src/api/deleteUser.ts b/frontend/src/api/requests/deleteUser.ts similarity index 64% rename from frontend/src/api/deleteUser.ts rename to frontend/src/api/requests/deleteUser.ts index 899803c..ecaa046 100644 --- a/frontend/src/api/deleteUser.ts +++ b/frontend/src/api/requests/deleteUser.ts @@ -1,10 +1,10 @@ import type { User } from "firebase/auth"; -import { auth } from "../firebase"; -import { setUser } from "../slices/user"; -import { store } from "../store"; -import apiClient from "./client"; +import { auth } from "../../firebase"; +import { setUser } from "../../slices/user"; +import { store } from "../../store"; +import apiClient from "../client"; -const deleteUser = async (user: User) => { +export const deleteUser = async (user: User) => { try { const token = await user.getIdToken(); await apiClient.delete('/profile/', { @@ -17,6 +17,4 @@ const deleteUser = async (user: User) => { } catch (e) { console.log(`Error occured ${e}`); } -} - -export default deleteUser; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/api/requests/index.ts b/frontend/src/api/requests/index.ts new file mode 100644 index 0000000..ba83041 --- /dev/null +++ b/frontend/src/api/requests/index.ts @@ -0,0 +1 @@ +export * from './deleteUser'; \ No newline at end of file diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index d686480..62e1ee3 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,8 +1,8 @@ import './Profile.css'; import { useSelector } from 'react-redux'; import { auth } from '../../firebase'; -import deleteUser from '../../api/deleteUser'; import type { RootState } from '../../store'; +import { deleteUser } from '@/api/requests'; const Profile = () => { const user = useSelector((s: RootState) => s.user); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index a9b5a59..6d23225 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -3,11 +3,22 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], "module": "ESNext", - "types": ["vite/client"], + "types": [ + "vite/client" + ], "skipLibCheck": true, - + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + }, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -15,7 +26,6 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, @@ -24,5 +34,7 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4abdc0c..9887a93 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,15 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], - envDir: '../' + envDir: '../', + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }) From 9589105044874d2d1547f541724d6d3f7e95a46b Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 26 Mar 2026 08:27:47 +0200 Subject: [PATCH 089/369] refactor: use react query for deleteUser request --- frontend/package-lock.json | 80 +++++++++++++++++++++++++ frontend/package.json | 3 + frontend/src/api/queryClient.ts | 5 ++ frontend/src/api/requests/deleteUser.ts | 5 -- frontend/src/main.tsx | 14 +++-- frontend/src/pages/Profile/Profile.tsx | 21 ++++++- 6 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 frontend/src/api/queryClient.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1517ad2..d5fd05b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,8 @@ "@lottiefiles/react-lottie-player": "^3.6.0", "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-query-devtools": "^5.95.2", "axios": "^1.13.6", "firebase": "^12.10.0", "react": "^19.2.0", @@ -22,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tanstack/eslint-plugin-query": "^5.95.2", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -2466,6 +2469,29 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.95.2.tgz", + "integrity": "sha512-EYUFRaqjBep4EHMPpZR12sXP7Kr5qv9iDIlq93NfbhHwhITaW6Txu3ROO6dLFz5r84T8p+oZXBG77pa2Wuok7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.48.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "^5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@tanstack/form-core": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.20.0.tgz", @@ -2479,6 +2505,26 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-core": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", + "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.95.2.tgz", + "integrity": "sha512-QfaoqBn9uAZ+ICkA8brd1EHj+qBF6glCFgt94U8XP5BT6ppSsDBI8IJ00BU+cAGjQzp6wcKJL2EmRYvxy0TWIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-form": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.20.0.tgz", @@ -2504,6 +2550,40 @@ } } }, + "node_modules/@tanstack/react-query": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", + "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.95.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.95.2.tgz", + "integrity": "sha512-AFQFmbznVkbtfpx8VJ2DylW17wWagQel/qLstVLkYmNRo2CmJt3SNej5hvl6EnEeljJIdC3BTB+W7HZtpsH+3g==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.95.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.95.2", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-store": { "version": "0.7.7", "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee9ef49..01edf75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "@lottiefiles/react-lottie-player": "^3.6.0", "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-query-devtools": "^5.95.2", "axios": "^1.13.6", "firebase": "^12.10.0", "react": "^19.2.0", @@ -24,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tanstack/eslint-plugin-query": "^5.95.2", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/api/queryClient.ts b/frontend/src/api/queryClient.ts new file mode 100644 index 0000000..2885559 --- /dev/null +++ b/frontend/src/api/queryClient.ts @@ -0,0 +1,5 @@ +import { + QueryClient +} from '@tanstack/react-query' + +export const queryClient = new QueryClient(); \ No newline at end of file diff --git a/frontend/src/api/requests/deleteUser.ts b/frontend/src/api/requests/deleteUser.ts index ecaa046..9363f57 100644 --- a/frontend/src/api/requests/deleteUser.ts +++ b/frontend/src/api/requests/deleteUser.ts @@ -1,7 +1,4 @@ import type { User } from "firebase/auth"; -import { auth } from "../../firebase"; -import { setUser } from "../../slices/user"; -import { store } from "../../store"; import apiClient from "../client"; export const deleteUser = async (user: User) => { @@ -12,8 +9,6 @@ export const deleteUser = async (user: User) => { Authorization: `Bearer ${token}`, }, }); - await auth.updateCurrentUser(null); - store.dispatch(setUser(null)); } catch (e) { console.log(`Error occured ${e}`); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 96438a3..288fee0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,13 +6,17 @@ import { App } from "./App"; import { FirebaseUIProvider } from "@firebase-oss/ui-react"; import { Provider } from "react-redux"; import { store } from "./store"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./api/queryClient"; createRoot(document.getElementById("root")!).render( - - - - - + + + + + + + , ); diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 62e1ee3..ff82d47 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,15 +1,30 @@ import './Profile.css'; import { useSelector } from 'react-redux'; import { auth } from '../../firebase'; -import type { RootState } from '../../store'; +import { store, type RootState } from '../../store'; import { deleteUser } from '@/api/requests'; +import { useMutation } from '@tanstack/react-query'; +import { setUser } from '@/slices/user'; const Profile = () => { const user = useSelector((s: RootState) => s.user); + const deleteUserMutation = useMutation({ + mutationKey: ['delete user'], + mutationFn: async () => { + if (!auth.currentUser) return; + await deleteUser(auth.currentUser); + }, + onSuccess: async () => { + await auth.updateCurrentUser(null); + store.dispatch(setUser(null)); + }, + onError: (e) => { + console.log('An error occured while trying to delete account', e.message) + } + }) const handleDeleteUser = async () => { - if (!user) return; if (!auth.currentUser) return; - deleteUser(auth.currentUser); + deleteUserMutation.mutate(); }; if (!user) return
    Loading...
    From 2051c2c80a076cd05585f847ed73835f6c79064e Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 27 Mar 2026 18:59:03 +0000 Subject: [PATCH 090/369] refactor: refactor tournaments endpoints and schemas --- .gitignore | 4 +- backend/app/main.py | 3 + backend/app/routes/tournament_teams.py | 85 ++++++++++++ backend/app/routes/tournaments.py | 185 ++++++++++++------------- backend/app/schemas/__init__.py | 8 +- backend/app/schemas/tournament.py | 95 ++++++++++--- 6 files changed, 266 insertions(+), 114 deletions(-) create mode 100644 backend/app/routes/tournament_teams.py diff --git a/.gitignore b/.gitignore index 6b6b3ed..c46b340 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,6 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -*.db \ No newline at end of file +*.db +serviceAccountKey.json +.vscode \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index b0a2ad4..d37b7bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,8 @@ import app.routes.submissions as submissions import app.routes.teams as teams import app.routes.team_members as team_members +import app.routes.tournament_teams as tournament_teams + app = FastAPI() app.include_router(tournaments.router) @@ -15,3 +17,4 @@ app.include_router(submissions.router) app.include_router(teams.router) app.include_router(team_members.router) +app.include_router(tournament_teams.router) diff --git a/backend/app/routes/tournament_teams.py b/backend/app/routes/tournament_teams.py new file mode 100644 index 0000000..d0f50df --- /dev/null +++ b/backend/app/routes/tournament_teams.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.schemas import TeamModel +from app.models import Team +from app.dependencies import SessionDep + +router = APIRouter(prefix="/tournaments/{tournament_id}/teams") + + +@router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) +async def tournament_participants( + tournament_id: int, + session: SessionDep, +): + statement = select(Team).where(Team.tournament_id == tournament_id) + teams = await session.execute(statement) + return teams.scalars().all() + + +@router.post( + "/", + response_model=TeamModel, + status_code=status.HTTP_201_CREATED, +) +async def register_team( + team: TeamModel, + session: SessionDep, +): + if await session.scalar(select(Team).where(Team.name == team.name)): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Team name already exists" + ) + + if await session.scalar(select(Team).where(Team.team_email == team.team_email)): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Team email already exists" + ) + + if await session.scalar(select(Team).where(Team.contact_info == team.contact_info)): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Contact info already exists", + ) + + new_team = Team(**team.model_dump()) + + session.add(new_team) + + try: + await session.commit() + await session.refresh(new_team) + + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=400, + detail="Team violates unique constraints", + ) + + return new_team + + +@router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) +async def leave_tournament( + tournament_id: int, + team_id: int, + session: SessionDep, +): + statement = select(Team).where( + Team.id == team_id, + Team.tournament_id == tournament_id, + ) + result = await session.execute(statement) + team = result.scalar_one_or_none() + + if not team: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="Team not found in this tournament", + ) + + await session.delete(team) + await session.commit() diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index b15f37b..1f1242b 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -1,128 +1,127 @@ from fastapi import APIRouter, HTTPException, status -from app.schemas import TournamentModels, TeamModel -from app.models import Tournament, Team +from sqlalchemy.orm import selectinload +from sqlalchemy import select, update +from pydantic import ValidationError + +from app.schemas import ( + TournamentRead, + TournamentCreate, + TournamentUpdate, +) +from app.models import Tournament, TournamentStatusOption from app.dependencies import SessionDep -from sqlalchemy import select -router = APIRouter(prefix='/tournaments', tags=['tournaments']) +router = APIRouter(prefix="/tournaments", tags=["tournaments"]) + + +async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: + statement = select(Tournament).where(Tournament.id == tournament_id) + tournament = (await session.execute(statement)).scalar_one_or_none() + if not tournament: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Tournament not found!") + return tournament + + +async def get_status_by_name(name: str, session: SessionDep) -> TournamentStatusOption: + statement = select(TournamentStatusOption).where( + TournamentStatusOption.name == name + ) + status_ = (await session.execute(statement)).scalar() + + if not status_: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return status_ + -@router.get('/', response_model=list[TournamentModels]) +@router.get("/", response_model=list[TournamentRead], status_code=status.HTTP_200_OK) async def tournaments(session: SessionDep): statement = select(Tournament) tournaments = await session.execute(statement) - return tournaments.all() + return tournaments.scalars().all() -@router.get('/{tournament_id}/', response_model=TournamentModels) + +@router.get( + "/{tournament_id}/", response_model=TournamentRead, status_code=status.HTTP_200_OK +) async def tournament(tournament_id: int, session: SessionDep): - statement = select(Tournament).where(Tournament.id==tournament_id) - tournament = await session.execute(statement) - if not tournament.first(): - raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') - return tournament.first() + return await get_tournament(tournament_id, session) + -@router.post('/', response_model=TournamentModels, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=TournamentRead, status_code=status.HTTP_201_CREATED) async def create_tournament( - creator_id: int, - status_id: int, - tournament: TournamentModels, + tournament: TournamentCreate, session: SessionDep, ): + draft_status = await get_status_by_name("Draft", session) + new_tournament = Tournament( - title=tournament.title, - description=tournament.description, - start_date=tournament.start_date, - reg_start=tournament.reg_start, - reg_end=tournament.reg_end, - max_team=tournament.max_team, - creator_id=creator_id, - status_id=status_id, + **tournament.model_dump(), + creator_id=1, + status_id=draft_status.id, ) session.add(new_tournament) await session.commit() - statement = select(Tournament).where(Tournament.id == new_tournament.id) - created = await session.execute(statement) - return created.first() + await session.refresh(new_tournament) + return new_tournament -@router.patch('/{tournament_id}/', response_model=TournamentModels) + +@router.patch( + "/{tournament_id}/", response_model=TournamentRead, status_code=status.HTTP_200_OK +) async def update_tournament( tournament_id: int, - tournament_data: TournamentModels, + tournament_data: TournamentUpdate, session: SessionDep, ): - statement = select(Tournament).where(Tournament.id == tournament_id) - result = await session.execute(statement) - tournament = result.scalar_one_or_none() - if not tournament: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') - tournament.title = tournament_data.title - tournament.description = tournament_data.description - tournament.start_date = tournament_data.start_date - tournament.reg_start = tournament_data.reg_start - tournament.reg_end = tournament_data.reg_end - tournament.max_team = tournament_data.max_team - await session.commit() - return tournament - + update_data = tournament_data.model_dump(exclude_unset=True) -@router.delete('/{tournament_id}/', status_code=status.HTTP_204_NO_CONTENT) -async def delete_tournament(tournament_id: int, session: SessionDep): - statement = select(Tournament).where(Tournament.id == tournament_id) - result = await session.execute(statement) - tournament = result.scalar_one_or_none() - if not tournament: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Tournament not found!') - await session.delete(tournament) - await session.commit() + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update", + ) + tournament = await get_tournament(tournament_id, session) -@router.post('/{tournament_id}/register', response_model=TeamModel, status_code=status.HTTP_201_CREATED) -async def register_team( - tournament_id: int, - team: TeamModel, - session: SessionDep, -): - new_team = Team( - name=team.name, - team_email=team.team_email, - contact_info=str(team.contact_info), - tournament_id=tournament_id, - captain_id=team.captain_id, - ) - session.add(new_team) - await session.commit() - statement = select(Team).where(Team.id == new_team.id) - created = await session.execute(statement) - return created.first() + merged_data = { + "title": tournament.title, + "description": tournament.description, + "start_date": tournament.start_date, + "reg_start": tournament.reg_start, + "reg_end": tournament.reg_end, + "max_team": tournament.max_team, + **update_data, + } + try: + TournamentCreate(**merged_data) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=e.errors()[0]["msg"], + ) -@router.post('/{tournament_id}/leave', status_code=status.HTTP_204_NO_CONTENT) -async def leave_tournament( - tournament_id: int, - team_id: int, - session: SessionDep, -): - statement = select(Team).where( - Team.id == team_id, - Team.tournament_id == tournament_id, + result = await session.execute( + update(Tournament) + .where(Tournament.id == tournament_id) + .values(**update_data) + .returning(Tournament) ) - result = await session.execute(statement) - team = result.scalar_one_or_none() - if not team: + updated_tournament = result.scalar_one_or_none() + if not updated_tournament: raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail='Team not found in this tournament', + status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) - await session.delete(team) + await session.commit() + return updated_tournament -@router.get('/{tournament_id}/participants', response_model=list[TeamModel]) -async def tournament_participants( - tournament_id: int, - session: SessionDep, -): - statement = select(Team).where(Team.tournament_id == tournament_id) - teams = await session.execute(statement) - return teams.all() +@router.delete("/{tournament_id}/", status_code=status.HTTP_204_NO_CONTENT) +async def delete_tournament(tournament_id: int, session: SessionDep): + tournament = await get_tournament(tournament_id, session) + await session.delete(tournament) + await session.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 9afbca2..e958fe6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,5 +2,11 @@ from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionModel from .task import TaskBase, TaskUpdate, TaskModel from .team import TeamModel, TeamUpdate, TeamMemberModel, TeamMemberUpdate -from .tournament import TournamentModels, TournamentStatusOptionModel +from .tournament import ( + TournamentBase, + TournamentUpdate, + TournamentCreate, + TournamentRead, + TournamentStatusOptionModel, +) from .user import UserModel, UserPublic, UserUpdate diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index b641fae..e7c461f 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -1,37 +1,94 @@ -from datetime import datetime +from typing import Annotated +from datetime import datetime, timezone from typing_extensions import Self -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) -class TournamentModels(BaseModel): - title: str = Field(..., min_length=3) +StrippedStr = Annotated[str, AfterValidator(lambda v: v.strip())] + + +class TournamentTimestampMixin(BaseModel): + + @field_validator( + "start_date", "reg_start", "reg_end", mode="before", check_fields=False + ) + @classmethod + def ensure_utc(cls, value): + if not value is None: + if isinstance(value, str): + value = datetime.fromisoformat(value) + + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + return value + + +class TournamentBase(TournamentTimestampMixin): + title: StrippedStr = Field(..., min_length=3) description: str start_date: datetime reg_start: datetime reg_end: datetime - max_team: int = Field(..., gt=0) + max_team: int = Field(..., gt=1) - @field_validator("title") - @classmethod - def check_title(cls, value: str): - return value.strip() - @field_validator("reg_start") - @classmethod - def check__date(cls, value: datetime): - if value <= datetime.now(): - raise ValueError("Registration cannot start in the past") - return value +class TournamentUpdate(TournamentTimestampMixin): + title: StrippedStr | None = Field(None, min_length=3) + description: str | None = None + start_date: datetime | None = None + reg_start: datetime | None = None + reg_end: datetime | None = None + max_team: int | None = Field(None, gt=1) + + @model_validator(mode="after") + def validate_update_dates(self) -> Self: + now = datetime.now(timezone.utc) + + if self.reg_start is not None and self.reg_start < now: + raise ValueError("Registration start cannot be in the past") + if self.reg_end is not None and self.reg_end < now: + raise ValueError("Registration end cannot be in the past") + if self.start_date is not None and self.start_date < now: + raise ValueError("Tournament start cannot be in the past") + + if self.reg_start and self.reg_end and self.reg_end <= self.reg_start: + raise ValueError("Registration end must be later than start") + if self.reg_end and self.start_date and self.start_date <= self.reg_end: + raise ValueError("Tournament must start after registration ends") + + return self + +class TournamentCreate(TournamentBase): @model_validator(mode="after") - def check_dates(self) -> Self: + def validate_dates(self) -> Self: + now = datetime.now(timezone.utc) + if self.reg_start < now: + raise ValueError("Registration cannot start in the past") if self.reg_end <= self.reg_start: - raise ValueError("reg_end must be later than reg_start") + raise ValueError("Registration end must be later than start") if self.start_date <= self.reg_end: raise ValueError("Tournament must start after registration ends") - return self +class TournamentRead(TournamentBase): + model_config = ConfigDict(from_attributes=True) + + id: int + status: TournamentStatusOptionModel + + class TournamentStatusOptionModel(BaseModel): - name: str = Field(..., min_length=3) + model_config = ConfigDict(from_attributes=True) + + id: int + name: StrippedStr = Field(..., min_length=3) From 5fb67c32c4944ec75aa15795356047b20ba624ae Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 27 Mar 2026 19:00:58 +0000 Subject: [PATCH 091/369] feat: add seed for status --- backend/app/core/__init__.py | 0 backend/app/core/seeds/__init__.py | 0 backend/app/core/seeds/status.py | 18 ++++++++++++++++++ backend/app/main.py | 16 +++++++++++++++- 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/seeds/__init__.py create mode 100644 backend/app/core/seeds/status.py diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/seeds/__init__.py b/backend/app/core/seeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/seeds/status.py b/backend/app/core/seeds/status.py new file mode 100644 index 0000000..f1c2134 --- /dev/null +++ b/backend/app/core/seeds/status.py @@ -0,0 +1,18 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import TournamentStatusOption + +DEFAULT_STATUSES = ["Draft", "Registration", "Running", "Finished"] + + +async def init_tournament_statuses(session: AsyncSession): + for status_name in DEFAULT_STATUSES: + statement = select(TournamentStatusOption).where( + TournamentStatusOption.name == status_name + ) + result = await session.execute(statement) + if not result.scalar_one_or_none(): + session.add(TournamentStatusOption(name=status_name)) + + await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index d37b7bc..f3e9b60 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,6 @@ from fastapi import FastAPI +from contextlib import asynccontextmanager + import app.routes.tournaments as tournaments import app.routes.users as users import app.routes.profile as profile @@ -8,8 +10,20 @@ import app.routes.team_members as team_members import app.routes.tournament_teams as tournament_teams +from app.core.seeds.status import init_tournament_statuses +from app.db import AsyncSessionLocal + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with AsyncSessionLocal() as session: + await init_tournament_statuses(session) + + yield + + +app = FastAPI(lifespan=lifespan) -app = FastAPI() app.include_router(tournaments.router) app.include_router(users.router) app.include_router(profile.router) From a9b011142c8cda2d6c88e9b88d75cece856487a6 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 28 Mar 2026 00:10:32 +0000 Subject: [PATCH 092/369] fix: bug fix in the PATCH endpoint related to dates --- backend/app/db.py | 4 +- backend/app/routes/tournaments.py | 56 ++++++++++-------- backend/app/schemas/tournament.py | 76 ++++--------------------- backend/app/utils/__init__.py | 0 backend/app/utils/routes/__init__.py | 0 backend/app/utils/routes/dates_logic.py | 76 +++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 90 deletions(-) create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/routes/__init__.py create mode 100644 backend/app/utils/routes/dates_logic.py diff --git a/backend/app/db.py b/backend/app/db.py index 0af2148..cd89141 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,10 +1,12 @@ +from collections.abc import AsyncGenerator from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession + from app.config import settings engine = create_async_engine(settings.SQLALCHEMY_DATABASE_URI, echo=True) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) -async def get_session() -> AsyncSession: +async def get_session() -> AsyncGenerator[AsyncSession, None]: async with AsyncSessionLocal() as sess: yield sess diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 1f1242b..02b15c1 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -10,12 +10,20 @@ ) from app.models import Tournament, TournamentStatusOption from app.dependencies import SessionDep +from app.utils.routes.dates_logic import ( + validate_dates_on_create, + validate_dates_on_update, +) router = APIRouter(prefix="/tournaments", tags=["tournaments"]) async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: - statement = select(Tournament).where(Tournament.id == tournament_id) + statement = ( + select(Tournament) + .where(Tournament.id == tournament_id) + .options(selectinload(Tournament.status)) + ) tournament = (await session.execute(statement)).scalar_one_or_none() if not tournament: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Tournament not found!") @@ -26,17 +34,17 @@ async def get_status_by_name(name: str, session: SessionDep) -> TournamentStatus statement = select(TournamentStatusOption).where( TournamentStatusOption.name == name ) - status_ = (await session.execute(statement)).scalar() + status_ = (await session.execute(statement)).scalar_one_or_none() if not status_: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) return status_ @router.get("/", response_model=list[TournamentRead], status_code=status.HTTP_200_OK) async def tournaments(session: SessionDep): - statement = select(Tournament) + statement = select(Tournament).options(selectinload(Tournament.status)) tournaments = await session.execute(statement) return tournaments.scalars().all() @@ -55,6 +63,12 @@ async def create_tournament( ): draft_status = await get_status_by_name("Draft", session) + validate_dates_on_create( + start_date=tournament.start_date, + reg_start=tournament.reg_start, + reg_end=tournament.reg_end, + ) + new_tournament = Tournament( **tournament.model_dump(), creator_id=1, @@ -76,7 +90,6 @@ async def update_tournament( session: SessionDep, ): update_data = tournament_data.model_dump(exclude_unset=True) - if not update_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -85,23 +98,15 @@ async def update_tournament( tournament = await get_tournament(tournament_id, session) - merged_data = { - "title": tournament.title, - "description": tournament.description, - "start_date": tournament.start_date, - "reg_start": tournament.reg_start, - "reg_end": tournament.reg_end, - "max_team": tournament.max_team, - **update_data, - } - - try: - TournamentCreate(**merged_data) - except ValidationError as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=e.errors()[0]["msg"], - ) + start_date = update_data.get("start_date", tournament.start_date) + reg_start = update_data.get("reg_start", tournament.reg_start) + reg_end = update_data.get("reg_end", tournament.reg_end) + + validate_dates_on_update( + start_date=start_date, + reg_start=reg_start, + reg_end=reg_end, + ) result = await session.execute( update(Tournament) @@ -109,14 +114,15 @@ async def update_tournament( .values(**update_data) .returning(Tournament) ) - updated_tournament = result.scalar_one_or_none() - if not updated_tournament: + updated_task = result.scalar_one_or_none() + + if not updated_task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) await session.commit() - return updated_tournament + return updated_task @router.delete("/{tournament_id}/", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index e7c461f..8605692 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -1,37 +1,12 @@ from typing import Annotated -from datetime import datetime, timezone -from typing_extensions import Self -from pydantic import ( - AfterValidator, - BaseModel, - ConfigDict, - Field, - field_validator, - model_validator, -) +from datetime import datetime +from pydantic import AfterValidator, BaseModel, ConfigDict, Field StrippedStr = Annotated[str, AfterValidator(lambda v: v.strip())] -class TournamentTimestampMixin(BaseModel): - - @field_validator( - "start_date", "reg_start", "reg_end", mode="before", check_fields=False - ) - @classmethod - def ensure_utc(cls, value): - if not value is None: - if isinstance(value, str): - value = datetime.fromisoformat(value) - - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) - return value - - -class TournamentBase(TournamentTimestampMixin): +class TournamentBase(BaseModel): title: StrippedStr = Field(..., min_length=3) description: str start_date: datetime @@ -40,7 +15,11 @@ class TournamentBase(TournamentTimestampMixin): max_team: int = Field(..., gt=1) -class TournamentUpdate(TournamentTimestampMixin): +class TournamentCreate(TournamentBase): + pass + + +class TournamentUpdate(BaseModel): title: StrippedStr | None = Field(None, min_length=3) description: str | None = None start_date: datetime | None = None @@ -48,47 +27,16 @@ class TournamentUpdate(TournamentTimestampMixin): reg_end: datetime | None = None max_team: int | None = Field(None, gt=1) - @model_validator(mode="after") - def validate_update_dates(self) -> Self: - now = datetime.now(timezone.utc) - - if self.reg_start is not None and self.reg_start < now: - raise ValueError("Registration start cannot be in the past") - if self.reg_end is not None and self.reg_end < now: - raise ValueError("Registration end cannot be in the past") - if self.start_date is not None and self.start_date < now: - raise ValueError("Tournament start cannot be in the past") - - if self.reg_start and self.reg_end and self.reg_end <= self.reg_start: - raise ValueError("Registration end must be later than start") - if self.reg_end and self.start_date and self.start_date <= self.reg_end: - raise ValueError("Tournament must start after registration ends") - - return self - -class TournamentCreate(TournamentBase): - @model_validator(mode="after") - def validate_dates(self) -> Self: - now = datetime.now(timezone.utc) - if self.reg_start < now: - raise ValueError("Registration cannot start in the past") - if self.reg_end <= self.reg_start: - raise ValueError("Registration end must be later than start") - if self.start_date <= self.reg_end: - raise ValueError("Tournament must start after registration ends") - return self - - -class TournamentRead(TournamentBase): +class TournamentStatusOptionModel(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - status: TournamentStatusOptionModel + name: StrippedStr = Field(..., min_length=3) -class TournamentStatusOptionModel(BaseModel): +class TournamentRead(TournamentBase): model_config = ConfigDict(from_attributes=True) id: int - name: StrippedStr = Field(..., min_length=3) + status: TournamentStatusOptionModel diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/routes/dates_logic.py b/backend/app/utils/routes/dates_logic.py new file mode 100644 index 0000000..9f8ca97 --- /dev/null +++ b/backend/app/utils/routes/dates_logic.py @@ -0,0 +1,76 @@ +from datetime import datetime, timezone +from fastapi import HTTPException, status + + +def to_utc(datetime): + if datetime is None: + return None + + if datetime.tzinfo is None: + return datetime.replace(tzinfo=timezone.utc) + + return datetime.astimezone(timezone.utc) + + +def validate_dates_on_create(start_date, reg_start, reg_end): + now = datetime.now(timezone.utc) + + if reg_start < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Registration cannot start in the past", + ) + + if reg_end <= reg_start: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Registration end must be later than start", + ) + + if start_date <= reg_end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tournament must start after registration ends", + ) + + +def validate_dates_on_update( + *, + start_date, + reg_start, + reg_end, +): + now = datetime.now(timezone.utc) + start_date = to_utc(start_date) + reg_start = to_utc(reg_start) + reg_end = to_utc(reg_end) + + if reg_start < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Registration start cannot be in the past", + ) + + if reg_end < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Registration end cannot be in the past", + ) + + if start_date < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tournament start cannot be in the past", + ) + + if reg_end <= reg_start: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Registration end must be later than start", + ) + + if start_date <= reg_end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tournament must start after registration ends", + ) From 54ba8deee2c5a9950f2653d55190288a3545b350 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 28 Mar 2026 00:18:57 +0000 Subject: [PATCH 093/369] refactor: renaming variables --- backend/app/routes/tournaments.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 02b15c1..6047a2b 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -18,6 +18,7 @@ router = APIRouter(prefix="/tournaments", tags=["tournaments"]) +# for future , move to a separate file: get_tournament, get_status_by_name async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: statement = ( select(Tournament) @@ -114,15 +115,15 @@ async def update_tournament( .values(**update_data) .returning(Tournament) ) - updated_task = result.scalar_one_or_none() + updated_tournament = result.scalar_one_or_none() - if not updated_task: + if not updated_tournament: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" + status_code=status.HTTP_404_NOT_FOUND, detail="Tournament not found" ) await session.commit() - return updated_task + return updated_tournament @router.delete("/{tournament_id}/", status_code=status.HTTP_204_NO_CONTENT) From 44d7a0f7c7891f5067edf090c04cd0ccf6ceb0b2 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 28 Mar 2026 10:59:04 +0200 Subject: [PATCH 094/369] feat: fetch user data from backend in onAuthStateChanged --- backend/app/models/user.py | 2 -- backend/app/routes/profile.py | 8 ++++++-- backend/app/routes/users.py | 6 +++--- backend/app/schemas/__init__.py | 4 ++-- backend/app/schemas/user.py | 6 +++++- frontend/src/api/requests/getProfile.ts | 16 ++++++++++++++++ frontend/src/api/requests/getUser.ts | 10 ++++++++++ frontend/src/api/requests/index.ts | 3 ++- frontend/src/firebase.ts | 15 +++++++++++++-- frontend/src/slices/user.ts | 11 ++++++++++- 10 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 frontend/src/api/requests/getProfile.ts create mode 100644 frontend/src/api/requests/getUser.ts diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 83a7d84..7fde780 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -32,8 +32,6 @@ class User(Base, PKMixin): back_populates="user", lazy="selectin", cascade="all, delete-orphan", ) - notifications: Mapped[list["Notification"]] = relationship(back_populates="user") - role_requests: Mapped[list["RoleRequest"]] = relationship(back_populates="user") created_tournaments: Mapped[list["Tournament"]] = relationship( back_populates="creator", lazy="selectin", cascade="all, delete-orphan", diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py index 71ca7b1..ec7f7d8 100644 --- a/backend/app/routes/profile.py +++ b/backend/app/routes/profile.py @@ -1,12 +1,16 @@ from fastapi import APIRouter, status -from app.schemas import UserUpdate, UserPublic +from app.schemas import UserUpdate, CurrentUser from app.dependencies import SessionDep, CurrentUserDep import firebase_admin.auth as auth from app.firebase import firebase router = APIRouter(prefix='/profile', tags=['profile']) -@router.patch('/', response_model=UserPublic) +@router.get('/', response_model=CurrentUser) +async def get_profile(current_user: CurrentUserDep): + return current_user + +@router.patch('/', response_model=CurrentUser) async def edit_profile(session: SessionDep, current_user: CurrentUserDep, user: UserUpdate): user_data = user.model_dump(exclude_unset=True) diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index 8b0f69f..8234497 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -27,6 +27,6 @@ async def users(session: SessionDep): users = await session.execute(statement) return users.scalars().all() -@router.get('/{user_id}/', response_model=UserPublic) -async def user(user_id: int, user: UserDep, session: SessionDep): - return user \ No newline at end of file +@router.get('/{identifier}/', response_model=UserPublic) +async def user(identifier: int | str, session: SessionDep): + return await get_user(identifier, session) \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d1b57d5..d6b03e8 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,6 +2,6 @@ from .task import TaskModel from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel -from .role_request import RoleRequestPublic, RoleRequestCreate from .notification import NotificationPublic, NotificationCreate -from .user import UserModel, UserPublic, UserUpdate, UserCreate +from .user import UserModel, UserPublic, UserUpdate, UserCreate, CurrentUser +from .role_request import RoleRequestPublic, RoleRequestCreate diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 5dc3415..3b245ee 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from .role import RolePublic +from .notification import NotificationPublic class UserBase(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -32,6 +33,9 @@ class UserModel(UserBase): def check_email(cls, value: str): return value.lower().strip() +from .role_request import RoleRequestPublic # Return notifications of current user only +# TODO: add created_tournaments after the TournamentPublic model will be defined class CurrentUser(UserPublic): - notifications: list[str] \ No newline at end of file + notifications: list[NotificationPublic] + role_requests: list[RoleRequestPublic] \ No newline at end of file diff --git a/frontend/src/api/requests/getProfile.ts b/frontend/src/api/requests/getProfile.ts new file mode 100644 index 0000000..75ad646 --- /dev/null +++ b/frontend/src/api/requests/getProfile.ts @@ -0,0 +1,16 @@ +import type { User } from "firebase/auth"; +import apiClient from "../client"; + +export const getProfile = async (user: User) => { + try { + const token = await user.getIdToken(); + const resp = await apiClient.get('/profile/', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return resp.data; + } catch (e) { + console.log(`Error occured ${e}`); + } +} \ No newline at end of file diff --git a/frontend/src/api/requests/getUser.ts b/frontend/src/api/requests/getUser.ts new file mode 100644 index 0000000..d839f14 --- /dev/null +++ b/frontend/src/api/requests/getUser.ts @@ -0,0 +1,10 @@ +import apiClient from "../client"; + +export const getUser = async (uid: string) => { + try { + const resp = await apiClient.get(`/users/${uid}/`); + return resp.data; + } catch (e) { + console.log(`Error occured ${e}`); + } +} \ No newline at end of file diff --git a/frontend/src/api/requests/index.ts b/frontend/src/api/requests/index.ts index ba83041..6261a7b 100644 --- a/frontend/src/api/requests/index.ts +++ b/frontend/src/api/requests/index.ts @@ -1 +1,2 @@ -export * from './deleteUser'; \ No newline at end of file +export * from './deleteUser'; +export * from './getUser'; \ No newline at end of file diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 2bd4568..af8389f 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -2,8 +2,10 @@ import { initializeApp, type FirebaseOptions } from "firebase/app"; import { getAnalytics } from "firebase/analytics"; import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence } from "firebase/auth"; import { initializeUI, providerPopupStrategy, requireDisplayName } from '@firebase-oss/ui-core'; -import { setUser, type FirebaseUserData } from "./slices/user"; +import { setUser, type ApiUserData, type FirebaseUserData, type UserData } from "./slices/user"; import { store } from "./store"; +import { queryClient } from "./api/queryClient"; +import { getProfile } from "./api/requests/getProfile"; const firebaseConfig: FirebaseOptions = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY, @@ -33,7 +35,7 @@ google.addScope('email'); onAuthStateChanged(auth, async (user) => { if (user) { - const userData: FirebaseUserData = { + const firebaseUserData: FirebaseUserData = { uid: user.uid, email: user.email, displayName: user.displayName, @@ -41,7 +43,16 @@ onAuthStateChanged(auth, async (user) => { emailVerified: user.emailVerified, isAnonymous: user.isAnonymous, }; + const apiUserData = await queryClient.fetchQuery({ + queryKey: ['user', user.uid], + queryFn: async () => await getProfile(user), + }); + const userData: UserData = { + ...firebaseUserData, + ...apiUserData, + }; store.dispatch(setUser(userData)); + console.log(userData); console.log('User authenticated!'); } else { store.dispatch(setUser(null)); diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index 6689b48..9c190bf 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -9,8 +9,17 @@ export interface FirebaseUserData { isAnonymous: boolean; } +export interface ApiUserData { + roles: object[]; + notifications: object[]; + role_requests: object[]; + created_tournaments: object[]; +} + +export interface UserData extends ApiUserData, FirebaseUserData { }; + interface UserState { - user: FirebaseUserData | null | undefined; + user: UserData | null | undefined; }; const initialUserState: UserState = { From 0f053c0185d752a7d32d6e32b8b963649afff695 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 28 Mar 2026 11:17:16 +0200 Subject: [PATCH 095/369] feat: implement forgot password functionality --- frontend/src/App.tsx | 2 ++ frontend/src/pages/Auth/ForgotPassword.tsx | 11 +++++++++++ frontend/src/pages/Auth/SignIn.tsx | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/Auth/ForgotPassword.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 968d92b..eef9c43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import SignOut from "./pages/Auth/SignOut"; import { Header } from "./components/Header"; import { Footer } from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; +import ForgotPassword from "./pages/Auth/ForgotPassword"; export const App = () => { return ( @@ -28,6 +29,7 @@ export const App = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Auth/ForgotPassword.tsx b/frontend/src/pages/Auth/ForgotPassword.tsx new file mode 100644 index 0000000..efc4986 --- /dev/null +++ b/frontend/src/pages/Auth/ForgotPassword.tsx @@ -0,0 +1,11 @@ +import { ForgotPasswordAuthScreen } from "@firebase-oss/ui-react" +import { useNavigate } from "react-router-dom"; + +const ForgotPassword = () => { + const navigate = useNavigate(); + return navigate('/auth/sign-in/')}> + + ; +} + +export default ForgotPassword; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx index 62a688b..cdba69f 100644 --- a/frontend/src/pages/Auth/SignIn.tsx +++ b/frontend/src/pages/Auth/SignIn.tsx @@ -8,7 +8,7 @@ const SignIn = () => { navigate('/'); }; return <> - + navigate('/auth/forgot-password/')}> Не маєте аккаунту? Створіть його! From 143f4b563a95d702b77b8ac472f2c9533f825ba7 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 28 Mar 2026 11:20:25 +0200 Subject: [PATCH 096/369] refactor: use onSignUpClick instead of for sign up link on sign in page --- frontend/src/pages/Auth/SignIn.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx index cdba69f..3dc2f16 100644 --- a/frontend/src/pages/Auth/SignIn.tsx +++ b/frontend/src/pages/Auth/SignIn.tsx @@ -1,5 +1,5 @@ import { GoogleSignInButton, SignInAuthScreen } from "@firebase-oss/ui-react"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { google } from "../../firebase"; const SignIn = () => { @@ -7,12 +7,12 @@ const SignIn = () => { const handleSignIn = () => { navigate('/'); }; - return <> - navigate('/auth/forgot-password/')}> - - Не маєте аккаунту? Створіть його! - - ; + return navigate('/auth/forgot-password/')} + onSignUpClick={() => navigate('/auth/sign-up')}> + + } export default SignIn; \ No newline at end of file From b66cc885d82d93fa7802172152957052240b8511 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sat, 28 Mar 2026 23:58:57 +0000 Subject: [PATCH 097/369] refactor: minor changes to the project --- backend/app/core/seeds/status.py | 13 ++-- backend/app/main.py | 2 - backend/app/models/tournament.py | 22 +++++-- backend/app/routes/teams.py | 84 +++++++++++++++++++++++-- backend/app/routes/tournament_teams.py | 85 -------------------------- 5 files changed, 105 insertions(+), 101 deletions(-) delete mode 100644 backend/app/routes/tournament_teams.py diff --git a/backend/app/core/seeds/status.py b/backend/app/core/seeds/status.py index f1c2134..b4d04bf 100644 --- a/backend/app/core/seeds/status.py +++ b/backend/app/core/seeds/status.py @@ -3,16 +3,21 @@ from app.models import TournamentStatusOption -DEFAULT_STATUSES = ["Draft", "Registration", "Running", "Finished"] +DEFAULT_STATUSES = { + "draft": "Draft", + "registration": "Registration", + "running": "Running", + "finished": "Finished", +} async def init_tournament_statuses(session: AsyncSession): - for status_name in DEFAULT_STATUSES: + for name, display in DEFAULT_STATUSES.items(): statement = select(TournamentStatusOption).where( - TournamentStatusOption.name == status_name + TournamentStatusOption.name == name ) result = await session.execute(statement) if not result.scalar_one_or_none(): - session.add(TournamentStatusOption(name=status_name)) + session.add(TournamentStatusOption(name=name, display_name=display)) await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index f3e9b60..e0355cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,6 @@ import app.routes.submissions as submissions import app.routes.teams as teams import app.routes.team_members as team_members -import app.routes.tournament_teams as tournament_teams from app.core.seeds.status import init_tournament_statuses from app.db import AsyncSessionLocal @@ -31,4 +30,3 @@ async def lifespan(app: FastAPI): app.include_router(submissions.router) app.include_router(teams.router) app.include_router(team_members.router) -app.include_router(tournament_teams.router) diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 1b14e67..f955527 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import List from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -20,17 +20,24 @@ class Tournament(Base, PKMixin): status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - teams: Mapped[list["Team"]] = relationship(back_populates="tournament", lazy="selectin") + teams: Mapped[list["Team"]] = relationship( + back_populates="tournament", lazy="selectin" + ) active_task: Mapped["Task"] = relationship( "Task", foreign_keys="Tournament.active_task_id", lazy="selectin" ) tasks: Mapped[list["Task"]] = relationship( - "Task", back_populates="tournament", foreign_keys="Task.tournament_id", lazy="selectin" + "Task", + back_populates="tournament", + foreign_keys="Task.tournament_id", + lazy="selectin", ) status: Mapped["TournamentStatusOption"] = relationship( back_populates="tournaments", lazy="selectin" ) - creator: Mapped["User"] = relationship(back_populates="created_tournaments", lazy="selectin") + creator: Mapped["User"] = relationship( + back_populates="created_tournaments", lazy="selectin" + ) def __repr__(self): return f"" @@ -39,6 +46,9 @@ def __repr__(self): class TournamentStatusOption(Base, PKMixin): __tablename__ = "tournament_status_options" - name: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] = mapped_column(unique=True, index=True) + display_name: Mapped[str] = mapped_column(nullable=False) - tournaments: Mapped[List["Tournament"]] = relationship(back_populates="status", lazy="selectin") + tournaments: Mapped[List["Tournament"]] = relationship( + back_populates="status", lazy="selectin" + ) diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py index 16c6996..fb8c374 100644 --- a/backend/app/routes/teams.py +++ b/backend/app/routes/teams.py @@ -3,10 +3,10 @@ from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from app.dependencies import SessionDep -from app.models import Team +from app.models import Team, Tournament from app.schemas import TeamModel, TeamUpdate -router = APIRouter(prefix="/teams", tags=["teams"]) +router = APIRouter(prefix="/tournaments/{tournament_id}/teams", tags=["teams"]) async def get_team(team_id: int, session: SessionDep) -> Team: @@ -18,8 +18,8 @@ async def get_team(team_id: int, session: SessionDep) -> Team: @router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) -async def teams(session: SessionDep): - statement = select(Team) +async def teams(tournament_id: int, session: SessionDep): + statement = select(Team).where(Team.tournament_id == tournament_id) teams = await session.execute(statement) return teams.scalars().all() @@ -87,3 +87,79 @@ async def delete_team(team_id: int, session: SessionDep): await session.delete(team) await session.commit() + + +# @router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) +# async def tournament_participants( +# tournament_id: int, +# session: SessionDep, +# ): +# statement = select(Team).where(Team.tournament_id == tournament_id) +# teams = await session.execute(statement) +# return teams.scalars().all() + + +# @router.post( +# "/", +# response_model=TeamModel, +# status_code=status.HTTP_201_CREATED, +# ) +# async def register_team( +# team: TeamModel, +# session: SessionDep, +# ): +# if await session.scalar(select(Team).where(Team.name == team.name)): +# raise HTTPException( +# status_code=status.HTTP_400_BAD_REQUEST, detail="Team name already exists" +# ) + +# if await session.scalar(select(Team).where(Team.team_email == team.team_email)): +# raise HTTPException( +# status_code=status.HTTP_400_BAD_REQUEST, detail="Team email already exists" +# ) + +# if await session.scalar(select(Team).where(Team.contact_info == team.contact_info)): +# raise HTTPException( +# status_code=status.HTTP_400_BAD_REQUEST, +# detail="Contact info already exists", +# ) + +# new_team = Team(**team.model_dump()) + +# session.add(new_team) + +# try: +# await session.commit() +# await session.refresh(new_team) + +# except IntegrityError: +# await session.rollback() +# raise HTTPException( +# status_code=400, +# detail="Team violates unique constraints", +# ) + +# return new_team + + +# @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) +# async def leave_tournament( +# tournament_id: int, +# team_id: int, +# session: SessionDep, +# ): +# statement = select(Team).where( +# Team.id == team_id, +# Team.tournament_id == tournament_id, +# ) +# result = await session.execute(statement) +# team = result.scalar_one_or_none() + +# if not team: +# raise HTTPException( +# status.HTTP_404_NOT_FOUND, +# detail="Team not found in this tournament", +# ) + +# await session.delete(team) +# await session.commit() diff --git a/backend/app/routes/tournament_teams.py b/backend/app/routes/tournament_teams.py deleted file mode 100644 index d0f50df..0000000 --- a/backend/app/routes/tournament_teams.py +++ /dev/null @@ -1,85 +0,0 @@ -from fastapi import APIRouter, HTTPException, status -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError - -from app.schemas import TeamModel -from app.models import Team -from app.dependencies import SessionDep - -router = APIRouter(prefix="/tournaments/{tournament_id}/teams") - - -@router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) -async def tournament_participants( - tournament_id: int, - session: SessionDep, -): - statement = select(Team).where(Team.tournament_id == tournament_id) - teams = await session.execute(statement) - return teams.scalars().all() - - -@router.post( - "/", - response_model=TeamModel, - status_code=status.HTTP_201_CREATED, -) -async def register_team( - team: TeamModel, - session: SessionDep, -): - if await session.scalar(select(Team).where(Team.name == team.name)): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Team name already exists" - ) - - if await session.scalar(select(Team).where(Team.team_email == team.team_email)): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Team email already exists" - ) - - if await session.scalar(select(Team).where(Team.contact_info == team.contact_info)): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Contact info already exists", - ) - - new_team = Team(**team.model_dump()) - - session.add(new_team) - - try: - await session.commit() - await session.refresh(new_team) - - except IntegrityError: - await session.rollback() - raise HTTPException( - status_code=400, - detail="Team violates unique constraints", - ) - - return new_team - - -@router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) -async def leave_tournament( - tournament_id: int, - team_id: int, - session: SessionDep, -): - statement = select(Team).where( - Team.id == team_id, - Team.tournament_id == tournament_id, - ) - result = await session.execute(statement) - team = result.scalar_one_or_none() - - if not team: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail="Team not found in this tournament", - ) - - await session.delete(team) - await session.commit() From 874c4ad74321a01a9c14daf2e630143cc1a7698b Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Sun, 29 Mar 2026 02:40:55 +0100 Subject: [PATCH 098/369] feat: add FSM for tournament statuses --- backend/app/core/seeds/status.py | 1 + backend/app/models/tournament.py | 2 +- backend/app/routes/tournaments.py | 21 ++--- backend/app/utils/routes/__init__.py | 2 + backend/app/utils/routes/tournament_status.py | 76 +++++++++++++++++++ 5 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 backend/app/utils/routes/tournament_status.py diff --git a/backend/app/core/seeds/status.py b/backend/app/core/seeds/status.py index b4d04bf..72749fd 100644 --- a/backend/app/core/seeds/status.py +++ b/backend/app/core/seeds/status.py @@ -8,6 +8,7 @@ "registration": "Registration", "running": "Running", "finished": "Finished", + "canceled": "Canceled", } diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index f955527..1019fc2 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime from typing import List from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index 6047a2b..c7400bb 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -1,19 +1,19 @@ from fastapi import APIRouter, HTTPException, status from sqlalchemy.orm import selectinload from sqlalchemy import select, update -from pydantic import ValidationError from app.schemas import ( TournamentRead, TournamentCreate, TournamentUpdate, ) -from app.models import Tournament, TournamentStatusOption +from app.models import Tournament from app.dependencies import SessionDep from app.utils.routes.dates_logic import ( validate_dates_on_create, validate_dates_on_update, ) +from app.utils.routes import auto_update_tournament_status, get_status_by_name router = APIRouter(prefix="/tournaments", tags=["tournaments"]) @@ -28,19 +28,10 @@ async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: tournament = (await session.execute(statement)).scalar_one_or_none() if not tournament: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Tournament not found!") - return tournament - -async def get_status_by_name(name: str, session: SessionDep) -> TournamentStatusOption: - statement = select(TournamentStatusOption).where( - TournamentStatusOption.name == name - ) - status_ = (await session.execute(statement)).scalar_one_or_none() + await auto_update_tournament_status(tournament, session) - if not status_: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) - - return status_ + return tournament @router.get("/", response_model=list[TournamentRead], status_code=status.HTTP_200_OK) @@ -62,7 +53,7 @@ async def create_tournament( tournament: TournamentCreate, session: SessionDep, ): - draft_status = await get_status_by_name("Draft", session) + initial_status = await get_status_by_name("draft", session) validate_dates_on_create( start_date=tournament.start_date, @@ -73,7 +64,7 @@ async def create_tournament( new_tournament = Tournament( **tournament.model_dump(), creator_id=1, - status_id=draft_status.id, + status_id=initial_status.id, ) session.add(new_tournament) await session.commit() diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py index e69de29..65463de 100644 --- a/backend/app/utils/routes/__init__.py +++ b/backend/app/utils/routes/__init__.py @@ -0,0 +1,2 @@ +from .dates_logic import validate_dates_on_create, validate_dates_on_update +from .tournament_status import auto_update_tournament_status, get_status_by_name diff --git a/backend/app/utils/routes/tournament_status.py b/backend/app/utils/routes/tournament_status.py new file mode 100644 index 0000000..2c2044e --- /dev/null +++ b/backend/app/utils/routes/tournament_status.py @@ -0,0 +1,76 @@ +from datetime import datetime, timezone +from fastapi import HTTPException, status +from sqlalchemy import select + + +from app.dependencies.session import SessionDep +from app.models import Tournament, TournamentStatusOption +from statemachine import StateMachine, State + + +class TournamentStatus(StateMachine): + draft = State("Draft", value="draft", initial=True) + registration = State("Registration", value="registration") + running = State("Running", value="running") + finished = State("Finished", value="finished", final=True) + canceled = State("Canceled", value="canceled", final=True) + + start_registration = draft.to(registration) + start_tournament = registration.to(running) + finish_tournament = running.to(finished) + + cancel = draft.to(canceled) | registration.to(canceled) | running.to(canceled) + + def __init__(self, tournament: Tournament, session): + self.tournament = tournament + self.session = session + self.tournament.status_name = tournament.status.name + + super().__init__(model=self.tournament, state_field="status_name") + + async def update_db_status(self): + + new_status_name = self.current_state.value + + statement = select(TournamentStatusOption).where( + TournamentStatusOption.name == new_status_name + ) + result = await self.session.execute(statement) + status_option = result.scalar_one() + + self.tournament.status_id = status_option.id + self.tournament.status = status_option + + await self.session.commit() + + +async def auto_update_tournament_status(tournament: Tournament, session): + now = datetime.now(timezone.utc).replace(tzinfo=None) + + fsm = TournamentStatus(tournament, session) + initial_state_value = fsm.current_state.value + + if fsm.current_state == TournamentStatus.draft: + if tournament.reg_start <= now: + fsm.start_registration() + + if fsm.current_state == TournamentStatus.registration: + if tournament.reg_end <= now: + fsm.start_tournament() + + if fsm.current_state.value != initial_state_value: + await fsm.update_db_status() + + await session.refresh(tournament, ["status"]) + + +async def get_status_by_name(name: str, session: SessionDep) -> TournamentStatusOption: + statement = select(TournamentStatusOption).where( + TournamentStatusOption.name == name + ) + status_ = (await session.execute(statement)).scalar_one_or_none() + + if not status_: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) + + return status_ From 8355a58d5ec8d708d30de2b08111e23ed35b15da Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 2 Apr 2026 15:28:38 +0300 Subject: [PATCH 099/369] feat: implement PATCH and DELETE /users/ routes --- backend/app/routes/users.py | 18 +++++++++++++++++- backend/app/schemas/user.py | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index 8234497..68a37a8 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, HTTPException, status, Depends from typing import Annotated -from app.schemas import UserPublic +from app.schemas import UserPublic, UserUpdate from app.models import User from app.dependencies import SessionDep from sqlalchemy import select, or_ @@ -27,6 +27,22 @@ async def users(session: SessionDep): users = await session.execute(statement) return users.scalars().all() +@router.patch('/{identifier}/', response_model=UserPublic) +async def edit_user(identifier: int | str, session: SessionDep, update_user: UserUpdate): + user_data = update_user.model_dump(exclude_unset=True) + user = await get_user(identifier, session) + user.full_name = user_data.get('full_name', user.full_name) + user.email = user_data.get('email', user.email) + await session.commit() + await session.refresh(user) + return user + +@router.delete('/{identifier}/', status_code=status.HTTP_204_NO_CONTENT) +async def delete_user(identifier: int | str, session: SessionDep): + user = await get_user(identifier, session) + await session.delete(user) + await session.commit() + @router.get('/{identifier}/', response_model=UserPublic) async def user(identifier: int | str, session: SessionDep): return await get_user(identifier, session) \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3b245ee..2d0010a 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -20,6 +20,7 @@ class UserCreate(UserBase): class UserUpdate(UserBase): full_name: str | None = None + email: str | None = None class UserPublic(UserBase): email: EmailStr From a5558241270c67ef96717b4fbcec72dc03fb150e Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 2 Apr 2026 15:31:16 +0300 Subject: [PATCH 100/369] feat: add firebase_uid field to UserPublic schema --- backend/app/schemas/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 2d0010a..62d932e 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -24,6 +24,7 @@ class UserUpdate(UserBase): class UserPublic(UserBase): email: EmailStr + firebase_uid: str roles: list[RolePublic] class UserModel(UserBase): From 7f66f4ad221d7a71c731a08f22b4f4137cd780fa Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 2 Apr 2026 15:50:11 +0300 Subject: [PATCH 101/369] feat: implement POST /users/ route --- backend/app/routes/users.py | 52 +++++++++++++++++++++++++++++++++---- backend/app/schemas/user.py | 2 +- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index 68a37a8..e142d09 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -1,12 +1,38 @@ from fastapi import APIRouter, HTTPException, status, Depends from typing import Annotated -from app.schemas import UserPublic, UserUpdate +from app.schemas import UserPublic, UserUpdate, UserCreate from app.models import User from app.dependencies import SessionDep from sqlalchemy import select, or_ router = APIRouter(prefix='/users', tags=['users']) +async def get_user_by_email(email: str, session: SessionDep): + statement = select(User).where(User.email==email) + + user = (await session.execute(statement)).scalar() + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') + return user + +async def get_user_by_firebase_uid(firebase_uid: str, session: SessionDep): + statement = select(User).where(User.firebase_uid==firebase_uid) + + user = (await session.execute(statement)).scalar() + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') + return user + +async def get_user_by_id(id: int, session: SessionDep): + statement = select(User).where(User.id==id) + + user = (await session.execute(statement)).scalar() + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found!') + return user +# We don't use the functions defined above, because if i.e. user by email is not found and +# the function is called first, the error will be thrown. +# TODO?: Possible refactoring required because of this async def get_user(identifier: str | int, session: SessionDep): if isinstance(identifier, str): statement = select(User).where(or_(User.firebase_uid==identifier, User.email==identifier)) @@ -27,6 +53,26 @@ async def users(session: SessionDep): users = await session.execute(statement) return users.scalars().all() +@router.get('/{identifier}/', response_model=UserPublic) +async def user(identifier: int | str, session: SessionDep): + return await get_user(identifier, session) + +@router.post('/', + response_model=UserPublic) +async def create_user( + session: SessionDep, + user_create: UserCreate +): + statement = select(User).where(or_(User.firebase_uid==user_create.firebase_uid, User.email==user_create.email)) + user = (await session.execute(statement)).scalar() + if not user: + user = User(**user_create.model_dump()) + session.add(user) + await session.commit() + await session.refresh(user) + return user + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='User already exists!') + @router.patch('/{identifier}/', response_model=UserPublic) async def edit_user(identifier: int | str, session: SessionDep, update_user: UserUpdate): user_data = update_user.model_dump(exclude_unset=True) @@ -42,7 +88,3 @@ async def delete_user(identifier: int | str, session: SessionDep): user = await get_user(identifier, session) await session.delete(user) await session.commit() - -@router.get('/{identifier}/', response_model=UserPublic) -async def user(identifier: int | str, session: SessionDep): - return await get_user(identifier, session) \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 62d932e..1d3a89c 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -15,7 +15,7 @@ def check_name(cls, value: str): return value class UserCreate(UserBase): - id: str = Field(..., description='Firebase user id') + firebase_uid: str = Field(..., description='Firebase user id') email: EmailStr = Field(..., description='Email') class UserUpdate(UserBase): From 6d6d24c1d7ea1925a04903f7e2773f968c7fdf99 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 2 Apr 2026 15:53:09 +0300 Subject: [PATCH 102/369] feat: implement GET /role-requests/ --- backend/app/routes/role_requests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/app/routes/role_requests.py b/backend/app/routes/role_requests.py index b31e0b1..011b805 100644 --- a/backend/app/routes/role_requests.py +++ b/backend/app/routes/role_requests.py @@ -29,6 +29,12 @@ async def get_admin_user(current_user: CurrentUserDep, session: SessionDep) -> U AdminUserDep = Annotated[User, Depends(get_admin_user)] +@router.get('/', response_model=list[RoleRequestPublic]) +async def role_requests(session: SessionDep): + statement = select(RoleRequest) + requests = await session.execute(statement) + return requests.scalars().all() + @router.post('/', response_model=RoleRequestPublic, dependencies=[Depends(get_current_user)]) From ec25ff2f302c7048ab94a93dc529cc6c4cf60453 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 2 Apr 2026 21:07:35 +0300 Subject: [PATCH 103/369] feat: add id field for UserPublic schema --- backend/app/schemas/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 1d3a89c..319855b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -23,6 +23,7 @@ class UserUpdate(UserBase): email: str | None = None class UserPublic(UserBase): + id: int email: EmailStr firebase_uid: str roles: list[RolePublic] From dd199d278b195e4a5a67597a79a8349cc324f917 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Thu, 2 Apr 2026 21:57:44 +0300 Subject: [PATCH 104/369] feat: additional info can be passed to RoleRequest --- backend/alembic/versions/f734dfe1b30d_.py | 32 ++++++++ backend/alembic/versions/fad843168dc7_.py | 92 +++++++++++++++++++++++ backend/app/models/__init__.py | 2 +- backend/app/models/role_request.py | 27 +++++-- backend/app/routes/role_requests.py | 38 +++++++--- backend/app/schemas/option.py | 8 ++ backend/app/schemas/role_request.py | 22 +++++- frontend/src/api/requests/getProfile.ts | 1 + 8 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 backend/alembic/versions/f734dfe1b30d_.py create mode 100644 backend/alembic/versions/fad843168dc7_.py create mode 100644 backend/app/schemas/option.py diff --git a/backend/alembic/versions/f734dfe1b30d_.py b/backend/alembic/versions/f734dfe1b30d_.py new file mode 100644 index 0000000..258d0ff --- /dev/null +++ b/backend/alembic/versions/f734dfe1b30d_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: f734dfe1b30d +Revises: fad843168dc7 +Create Date: 2026-04-02 21:41:39.029760 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f734dfe1b30d' +down_revision: Union[str, Sequence[str], None] = 'fad843168dc7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/alembic/versions/fad843168dc7_.py b/backend/alembic/versions/fad843168dc7_.py new file mode 100644 index 0000000..24dc438 --- /dev/null +++ b/backend/alembic/versions/fad843168dc7_.py @@ -0,0 +1,92 @@ +"""empty message + +Revision ID: fad843168dc7 +Revises: b09774274996 +Create Date: 2026-04-02 21:27:33.721263 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fad843168dc7' +down_revision: Union[str, Sequence[str], None] = 'b09774274996' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('role_request_info_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('submission_url_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('role_request_info', + sa.Column('request_id', sa.Integer(), nullable=False), + sa.Column('option_name', sa.String(), nullable=False), + sa.Column('value', sa.String(length=4096), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['option_name'], ['role_request_info_options.name'], ), + sa.ForeignKeyConstraint(['request_id'], ['role_requests.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('submissions', + sa.Column('team_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ), + sa.PrimaryKeyConstraint('team_id') + ) + op.create_table('evaluations', + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('jury_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('submission_id', 'jury_id') + ) + op.create_table('submission_urls', + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('url_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), + sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ), + sa.PrimaryKeyConstraint('submission_id', 'url_id') + ) + op.create_table('requirement_evaluations', + sa.Column('evaluation_id', sa.Integer(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('evaluation_requirements', + sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False), + sa.Column('requirement_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('evaluation_requirements') + op.drop_table('requirement_evaluations') + op.drop_table('submission_urls') + op.drop_table('evaluations') + op.drop_table('submissions') + op.drop_table('role_request_info') + op.drop_table('submission_url_options') + op.drop_table('role_request_info_options') + # ### end Alembic commands ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 752f0e8..e544953 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,7 @@ from .base import Base from .evaluation import SubmissionEvaluation, RequirementEvaluation from .notification import Notification -from .role_request import RoleRequest +from .role_request import RoleRequest, RoleRequestInfo, RoleRequestInfoOption from .role import Role from .submission import Submission, SubmissionUrl, SubmissionUrlOption from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py index 87c4a61..e2a3db6 100644 --- a/backend/app/models/role_request.py +++ b/backend/app/models/role_request.py @@ -1,8 +1,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import ForeignKey - +from sqlalchemy import ForeignKey, String from .base import Base -from .mixin import PKMixin +from .mixin import PKMixin, OptionMixin class RoleRequest(Base, PKMixin): @@ -13,8 +12,26 @@ class RoleRequest(Base, PKMixin): role: Mapped["Role"] = relationship(back_populates="requests", lazy="selectin") user: Mapped["User"] = relationship(back_populates="role_requests", lazy="selectin") + info: Mapped[list['RoleRequestInfo']] = relationship( + back_populates='request', + lazy='selectin', + cascade="all, delete-orphan" + ) def __repr__(self): return f"" -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import ForeignKey + +class RoleRequestInfoOption(Base, OptionMixin): + __tablename__ = 'role_request_info_options' + +class RoleRequestInfo(Base, PKMixin): + __tablename__ = 'role_request_info' + request_id: Mapped[int] = mapped_column(ForeignKey('role_requests.id')) + option_name: Mapped[str] = mapped_column(ForeignKey('role_request_info_options.name')) + value: Mapped[str] = mapped_column(String(4096)) + + request: Mapped[RoleRequest] = relationship(back_populates='info', lazy='selectin') + option: Mapped[RoleRequestInfoOption] = relationship(lazy='selectin') + + def __repr__(self): + return f"" diff --git a/backend/app/routes/role_requests.py b/backend/app/routes/role_requests.py index 011b805..f13e9a0 100644 --- a/backend/app/routes/role_requests.py +++ b/backend/app/routes/role_requests.py @@ -3,13 +3,22 @@ from typing import Annotated from app.dependencies import SessionDep, CurrentUserDep, get_current_user from sqlalchemy import select -from app.models import RoleRequest, User, Role +from sqlalchemy.orm import selectinload +from app.models import RoleRequest, User, Role, RoleRequestInfo from app.schemas import RoleRequestPublic, RoleRequestCreate, UserPublic router = APIRouter(prefix='/role-requests', tags=['role requests']) async def get_role_request(request_id: int, session: SessionDep) -> RoleRequest: - statement = select(RoleRequest).where(RoleRequest.id==request_id) + statement = ( + select(RoleRequest) + .options( + selectinload(RoleRequest.info).selectinload(RoleRequestInfo.option), + selectinload(RoleRequest.role), + selectinload(RoleRequest.user), + ) + .where(RoleRequest.id==request_id) + ) result = await session.execute(statement) request = result.scalar() if not request: @@ -31,7 +40,11 @@ async def get_admin_user(current_user: CurrentUserDep, session: SessionDep) -> U @router.get('/', response_model=list[RoleRequestPublic]) async def role_requests(session: SessionDep): - statement = select(RoleRequest) + statement = select(RoleRequest).options( + selectinload(RoleRequest.info).selectinload(RoleRequestInfo.option), + selectinload(RoleRequest.role), + selectinload(RoleRequest.user), + ) requests = await session.execute(statement) return requests.scalars().all() @@ -40,19 +53,24 @@ async def role_requests(session: SessionDep): dependencies=[Depends(get_current_user)]) async def create_request( session: SessionDep, - request: RoleRequestCreate + request_create: RoleRequestCreate ): - request = RoleRequestCreate.model_validate(request) + request = RoleRequestCreate.model_validate(request_create) statement = select(RoleRequest).where(RoleRequest.role_id==request.role_id, RoleRequest.user_id==request.user_id) r = (await session.execute(statement)).scalar() if r: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Role requests already exists!') - request = RoleRequest(**request.model_dump()) - session.add(request) + request_data = request.model_dump() + info = request_data.pop('info') + role_request = RoleRequest(**request_data) + session.add(role_request) + await session.flush() + for j in info: + i = RoleRequestInfo(**j, request_id=role_request.id) + session.add(i) await session.commit() - await session.refresh(request) - return request + return await get_role_request(role_request.id, session) @router.get('/{request_id}/', response_model=RoleRequestPublic) async def get_request(request_id: int, session: SessionDep, request: RoleRequestDep): @@ -78,4 +96,4 @@ async def disapprove_request(request_id: int, request: RoleRequestDep, session: dependencies=[Depends(get_current_user)]) async def delete_request(request_id: int, request: RoleRequestDep, session: SessionDep, current_user: CurrentUserDep): await session.delete(request) - await session.commit() \ No newline at end of file + await session.commit() diff --git a/backend/app/schemas/option.py b/backend/app/schemas/option.py new file mode 100644 index 0000000..f50303c --- /dev/null +++ b/backend/app/schemas/option.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field, ConfigDict + +class OptionBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str = Field(..., description='Option name') + display_name: str = Field(..., description='Option description') + \ No newline at end of file diff --git a/backend/app/schemas/role_request.py b/backend/app/schemas/role_request.py index f3fb581..7a089c3 100644 --- a/backend/app/schemas/role_request.py +++ b/backend/app/schemas/role_request.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, Field, ConfigDict from .user import UserPublic from .role import RolePublic +from .option import OptionBase class RoleRequestBase(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -8,10 +9,29 @@ class RoleRequestBase(BaseModel): role_id: int = Field(..., description="Role being requested") user_id: int = Field(..., description="User requesting the role") +class RoleRequestInfoOptionBase(OptionBase): + model_config = ConfigDict(from_attributes=True) + +class RoleRequestInfoOptionPublic(RoleRequestInfoOptionBase): + pass + +class RoleRequestInfoBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + option_name: str = Field(..., description="Request info option name") + value: str = Field(..., description='The value of the info', max_length=4096) + +class RoleReqestInfoPublic(RoleRequestInfoBase): + id: int + request_id: int = Field(..., description="Role request id") + # request: 'RoleRequestPublic' + option: 'RoleRequestInfoOptionPublic' + class RoleRequestPublic(RoleRequestBase): id: int user: UserPublic role: RolePublic + info: list[RoleReqestInfoPublic] class RoleRequestCreate(RoleRequestBase): - pass \ No newline at end of file + info: list[RoleRequestInfoBase] = Field(default_factory=list) diff --git a/frontend/src/api/requests/getProfile.ts b/frontend/src/api/requests/getProfile.ts index 75ad646..a5864d4 100644 --- a/frontend/src/api/requests/getProfile.ts +++ b/frontend/src/api/requests/getProfile.ts @@ -4,6 +4,7 @@ import apiClient from "../client"; export const getProfile = async (user: User) => { try { const token = await user.getIdToken(); + console.log(token) const resp = await apiClient.get('/profile/', { headers: { Authorization: `Bearer ${token}`, From 486a82803103be9f563204c244a2e60a510c459d Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Fri, 27 Mar 2026 17:02:25 +0200 Subject: [PATCH 105/369] feat(GetOrganizerRole): Add page for getting organizer role --- frontend/package-lock.json | 14 ++ frontend/package.json | 1 + frontend/src/App.tsx | 2 + .../pages/GetOganizerRole/GetOganizerRole.tsx | 70 +++++++ .../GetOganizerRole/GetOrganizerRole.css | 192 ++++++++++++++++++ frontend/src/pages/GetOganizerRole/star.json | 1 + 6 files changed, 280 insertions(+) create mode 100644 frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx create mode 100644 frontend/src/pages/GetOganizerRole/GetOrganizerRole.css create mode 100644 frontend/src/pages/GetOganizerRole/star.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d5fd05b..2488475 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-query-devtools": "^5.95.2", "axios": "^1.13.6", "firebase": "^12.10.0", + "lottie-react": "^2.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-redux": "^9.2.0", @@ -4584,6 +4585,19 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lottie-react": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", + "integrity": "sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==", + "license": "MIT", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lottie-web": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 01edf75..495b526 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@tanstack/react-query-devtools": "^5.95.2", "axios": "^1.13.6", "firebase": "^12.10.0", + "lottie-react": "^2.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-redux": "^9.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eef9c43..8ca9866 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { Header } from "./components/Header"; import { Footer } from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; +import { GetOrganizerRole } from "./pages/GetOganizerRole/GetOganizerRole"; export const App = () => { return ( @@ -32,6 +33,7 @@ export const App = () => { } /> } /> + } /> } />
    diff --git a/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx b/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx new file mode 100644 index 0000000..70b2120 --- /dev/null +++ b/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx @@ -0,0 +1,70 @@ +import './GetOrganizerRole.css'; +import Lottie from 'lottie-react'; +import star from './star.json'; + +const GetOrganizerRole = () => { + return( +
    +
    +
    +
    + +
    +

    UGalaxy

    +

    STAR FOR LIFE

    +

    Заявка на роль організатора

    +

    + Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну. +

    + Важливо: відповідайте чесно та детально — це підвищує ваші шанси. +

    +
    +
    + +
    +
    +

    * — обов'язкове поле

    +
    e.preventDefault()}> +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    +
    +
    +
    + ) +} + +export { GetOrganizerRole } \ No newline at end of file diff --git a/frontend/src/pages/GetOganizerRole/GetOrganizerRole.css b/frontend/src/pages/GetOganizerRole/GetOrganizerRole.css new file mode 100644 index 0000000..b4e97e0 --- /dev/null +++ b/frontend/src/pages/GetOganizerRole/GetOrganizerRole.css @@ -0,0 +1,192 @@ +/* --- Layout --- */ +.page-layout { + display: flex; + min-height: 100vh; + font-family: 'Inter', 'Roboto', sans-serif; + background-color: #f7f8fc; +} + +/* --- Brand Panel --- */ +.brand-panel { + flex: 1; + background-color: #7b00ff; + color: white; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; +} + +.brand-content { + max-width: 480px; + text-align: left; +} + +.logo-placeholder { + font-size: 64px; + margin-bottom: 10px; + text-align: center; +} + +.brand-content h2 { + font-size: 32px; + margin: 0; + font-weight: 800; + text-align: center; +} + +.brand-subtitle { + font-size: 14px; + letter-spacing: 2px; + margin: 5px 0 40px 0; + opacity: 0.9; + text-align: center; +} + +/* --- Form Panel & Typography --- */ +.form-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; +} + +.form-card { + background: #ffffff; + width: 100%; + max-width: 420px; + padding: 40px; + border-radius: 24px; + box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.04); +} + +.form-title { + font-size: 28px; + font-weight: 800; + color: #ffffff; + margin-top: 0; + margin-bottom: 16px; + line-height: 1.2; +} + +.form-description { + font-size: 15px; + color: rgba(255, 255, 255, 0.85); + line-height: 1.6; + margin-bottom: 24px; +} + +.form-note { + font-size: 13px; + color: #7b00ff; + margin-bottom: 0; +} + +/* --- Form Elements (Floating Labels Version) --- */ +.role-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.input-group { + position: relative; + width: 100%; +} + +.role-form input, +.role-form textarea { + width: 100%; + padding: 24px 18px 8px 18px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background-color: #fafafa; + font-size: 14px; + color: #333; + transition: all 0.2s ease; + box-sizing: border-box; + font-family: inherit; +} + +.role-form textarea { + resize: vertical; + min-height: 80px; +} + +.role-form input:focus, +.role-form textarea:focus { + outline: none; + border-color: #7b00ff; + background-color: #ffffff; + box-shadow: 0 0 0 3px rgba(123, 0, 255, 0.1); +} + +.role-form input::placeholder, +.role-form textarea::placeholder { + color: #9ca3af; +} + +/* --- Floating Label Animations --- */ +.input-group label { + position: absolute; + top: 18px; + left: 18px; + font-size: 14px; + color: #9ca3af; + pointer-events: none; + transition: all 0.2s ease; +} + +.role-form input:focus ~ label, +.role-form input:not(:placeholder-shown) ~ label, +.role-form textarea:focus ~ label, +.role-form textarea:not(:placeholder-shown) ~ label { + top: 6px; + font-size: 11px; + color: #7b00ff; + font-weight: 500; +} + +.role-form input:not(:focus):not(:placeholder-shown) ~ label, +.role-form textarea:not(:focus):not(:placeholder-shown) ~ label { + color: #6b7280; +} + +/* --- Submit Button --- */ +.submit-button { + margin-top: 10px; + padding: 16px; + background-color: #7b00ff; + color: #ffffff; + border: none; + border-radius: 100px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; +} + +.submit-button:hover { + background-color: #6a00e0; +} + +.submit-button:active { + transform: scale(0.98); +} + +/* --- Media Queries --- */ +@media (max-width: 900px) { + .page-layout { + flex-direction: column; + } + + .brand-panel { + padding: 40px 20px; + } + + .brand-content { + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/src/pages/GetOganizerRole/star.json b/frontend/src/pages/GetOganizerRole/star.json new file mode 100644 index 0000000..ebd5321 --- /dev/null +++ b/frontend/src/pages/GetOganizerRole/star.json @@ -0,0 +1 @@ +{"tgs":1,"v":"5.5.2","fr":60,"ip":0,"op":140,"w":512,"h":512,"nm":"STAR_22","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 44","sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":0,"k":[233.2,286.013,0]},"a":{"a":0,"k":[50,50,0]},"s":{"a":0,"k":[90,90,100]}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"HAND02","parent":14,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[4]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":50,"s":[-128]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[-136]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.69],"y":[0]},"t":90,"s":[0]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":103,"s":[-23]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.43],"y":[0]},"t":127,"s":[0]},{"t":140,"s":[0]}]},"p":{"a":0,"k":[-235.482,83.112,0]},"a":{"a":0,"k":[-235.482,83.112,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[{"i":[[0.47,-4.074],[-15.053,-12.579],[-3.152,-2.41],[12.5,10.25],[14.965,6.179]],"o":[[-0.944,8.18],[8.87,7.412],[23,10.75],[-5.391,-4.421],[-19.731,-8.147]],"v":[[-301.25,39],[-269.872,76.056],[-249.75,92],[-225.75,67.25],[-260.739,49.454]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0.498,-4.318],[-14.623,-12.361],[-3.351,-2.563],[12.5,10.25],[4.69,1.266]],"o":[[-0.909,7.874],[9.518,8.046],[23,10.75],[-4.965,-4.071],[-6.961,-1.88]],"v":[[-301.25,39],[-271.567,74.631],[-249.75,92],[-225.75,67.25],[-257.199,50.938]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[{"i":[[13.378,2.355],[-8.156,-8.892],[-17.931,-3.037],[11.278,11.581],[4.331,2.2]],"o":[[-9.885,-1.74],[6.808,7.422],[18.6,1.94],[-4.48,-4.6],[-4.841,-2.458]],"v":[[-283.677,46.109],[-303.818,92.534],[-267.062,104.945],[-247.106,74.312],[-280.573,75.716]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[6.924,-0.86],[-11.605,-10.736],[-10.541,-2.724],[11.889,10.915],[9.648,4.189]],"o":[[-5.414,3.22],[7.839,7.417],[20.8,6.345],[-4.936,-4.51],[-12.286,-5.303]],"v":[[-292.464,42.554],[-286.845,84.295],[-258.406,98.473],[-236.428,70.781],[-270.656,62.585]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":80,"s":[{"i":[[0.47,-4.074],[-15.053,-12.579],[-3.152,-2.41],[12.5,10.25],[14.965,6.179]],"o":[[-0.944,8.18],[8.87,7.412],[23,10.75],[-5.391,-4.421],[-19.731,-8.147]],"v":[[-301.25,39],[-269.872,76.056],[-249.75,92],[-225.75,67.25],[-260.739,49.454]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":90,"s":[{"i":[[0.47,-4.074],[-16.352,-14.036],[-3.152,-2.41],[12.515,10.231],[14.965,6.179]],"o":[[-0.944,8.18],[11.844,10.167],[23,10.75],[-4.351,-3.557],[-19.731,-8.147]],"v":[[-315.572,37.232],[-278.047,71.015],[-249.75,92],[-225.75,67.25],[-263.534,51.309]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[0.47,-4.074],[-15.053,-12.579],[-7.836,-1.32],[12.5,10.25],[6.109,1.957]],"o":[[-0.944,8.18],[8.87,7.412],[18.612,4.905],[-5.391,-4.421],[-7.788,-2.495]],"v":[[-294.279,42.956],[-272.48,83.685],[-246.385,93.085],[-225.75,67.25],[-255.477,61.478]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0},"t":127,"s":[{"i":[[1.294,-3.891],[-12.159,-15.395],[-2.591,-3.004],[10.137,12.592],[13.383,9.112]],"o":[[-2.598,7.814],[7.165,9.071],[20.312,15.231],[-4.372,-5.431],[-17.646,-12.014]],"v":[[-294.707,26.751],[-271.58,69.445],[-255.148,89.171],[-226.59,69.859],[-257.195,45.277]],"c":true}]},{"t":140,"s":[{"i":[[0.47,-4.074],[-15.053,-12.579],[-3.152,-2.41],[12.5,10.25],[14.965,6.179]],"o":[[-0.944,8.18],[8.87,7.412],[23,10.75],[-5.391,-4.421],[-19.731,-8.147]],"v":[[-301.25,39],[-269.872,76.056],[-249.75,92],[-225.75,67.25],[-260.739,49.454]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"LIPS","parent":18,"sr":1,"ks":{"r":{"a":0,"k":-37},"p":{"a":0,"k":[-195.929,38.343,0]},"a":{"a":0,"k":[-216.286,15.538,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[8.006,-0.114],[-19.5,5.25],[-6.5,-10]],"o":[[-8.972,0.128],[-3.25,-19.5],[5.73,8.816]],"v":[[-218.778,33.622],[-220.75,13.25],[-201.5,4.25]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.003921568627,0.003921568627,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[11.52,10.383],[-16.424,-8.986],[-5.923,10.352]],"o":[[-11.52,-10.383],[16.424,8.986],[5.923,-10.352]],"v":[[-195.332,8.16],[-216.504,34.782],[-189.037,31.346]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.003921568627,0.003921568627,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"SAX 3","parent":8,"sr":1,"ks":{"p":{"a":0,"k":[-262.496,-44.339,0]},"a":{"a":0,"k":[-262.496,-44.339,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-348.774,-70.184],[-341.822,7.886],[-330.551,-0.053],[-341.443,-71.089]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.89019613827,0.576470588235,0.145098039216,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 10","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-347.397,5.403],[-354.218,-69.538]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.823529471603,0.352941176471,0.050980395897,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 9","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":0,"s":[{"i":[[8.545,8.273],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-357.559,-119.145],[-364.664,-111.677],[-413.452,-39.488],[-490.592,15.612],[-500.405,23.55],[-404.874,-34.434]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[14.598,9.418],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-351.127,-108.693],[-359.95,-100.627],[-402.894,-24.908],[-480.034,30.192],[-489.847,38.13],[-394.316,-19.854]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":20,"s":[{"i":[[8.545,8.273],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-357.559,-119.145],[-364.664,-111.677],[-413.452,-39.488],[-490.592,15.612],[-500.405,23.55],[-404.874,-34.434]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[8.545,8.273],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-357.559,-119.145],[-364.664,-111.677],[-413.452,-39.488],[-490.592,15.612],[-500.405,23.55],[-404.874,-34.434]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":90,"s":[{"i":[[9.286,7.433],[0,0],[47.708,-65.99],[9.524,3.229],[2.584,-4.079],[-50.306,59.056]],"o":[[-2.325,2.637],[0,0],[-23.781,32.894],[-5.126,3.158],[7.564,4.362],[43.215,-50.732]],"v":[[-394.89,-153.595],[-402.864,-145.532],[-438.893,-72.743],[-511.461,-17.953],[-519.3,-9.763],[-433.56,-68.019]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[8.545,8.273],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-357.559,-119.145],[-364.664,-111.677],[-413.452,-39.488],[-490.592,15.612],[-500.405,23.55],[-404.874,-34.434]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":127,"s":[{"i":[[9.286,7.433],[0,0],[47.708,-65.99],[9.524,3.229],[2.584,-4.079],[-50.306,59.056]],"o":[[-2.325,2.637],[0,0],[-23.781,32.894],[-5.126,3.158],[7.564,4.362],[43.215,-50.732]],"v":[[-394.89,-153.595],[-402.864,-145.532],[-438.893,-72.743],[-511.461,-17.953],[-519.3,-9.763],[-433.56,-68.019]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[14.598,9.418],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-351.127,-108.693],[-359.95,-100.627],[-402.894,-24.908],[-480.034,30.192],[-489.847,38.13],[-394.316,-19.854]],"c":true}]},{"t":140,"s":[{"i":[[8.545,8.273],[0,0],[63.387,-68.19],[9.178,4.11],[2.956,-3.818],[-63.628,64.481]],"o":[[-2.563,2.406],[0,0],[-27.635,29.73],[-5.401,2.662],[7.12,5.055],[53.695,-54.414]],"v":[[-357.559,-119.145],[-364.664,-111.677],[-413.452,-39.488],[-490.592,15.612],[-500.405,23.55],[-404.874,-34.434]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":0,"s":[{"i":[[-0.594,9.69],[-2.162,-6.375],[27.037,-27.328],[6.732,1.496],[-0.087,-2.572],[-12.299,10.738]],"o":[[0.594,-9.69],[1.89,5.573],[-19.805,20.019],[-2.141,4.203],[25.341,2.857],[21.425,-18.707]],"v":[[-359.473,-106.921],[-378.033,-98.978],[-421.172,-43.599],[-478.725,0.898],[-493.462,19.087],[-408.092,-38.334]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[-0.482,9.696],[-1.982,-16.188],[26.719,-27.639],[6.749,1.418],[-0.117,-2.571],[-12.174,10.88]],"o":[[0.482,-9.696],[0.955,7.799],[-19.572,20.247],[-2.092,4.228],[25.372,2.563],[21.208,-18.954]],"v":[[-350.373,-95.934],[-368.673,-86.67],[-412.623,-30.691],[-469.656,14.47],[-484.182,32.827],[-399.483,-25.577]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":20,"s":[{"i":[[-0.594,9.69],[-2.162,-6.375],[27.037,-27.328],[6.732,1.496],[-0.087,-2.572],[-12.299,10.738]],"o":[[0.594,-9.69],[1.89,5.573],[-19.805,20.019],[-2.141,4.203],[25.341,2.857],[21.425,-18.707]],"v":[[-359.473,-106.921],[-378.033,-98.978],[-421.172,-43.599],[-478.725,0.898],[-493.462,19.087],[-408.092,-38.334]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[-0.594,9.69],[-2.162,-6.375],[27.037,-27.328],[6.732,1.496],[-0.087,-2.572],[-12.299,10.738]],"o":[[0.594,-9.69],[1.89,5.573],[-19.805,20.019],[-2.141,4.203],[25.341,2.857],[21.425,-18.707]],"v":[[-359.473,-106.921],[-378.033,-98.978],[-421.172,-43.599],[-478.725,0.898],[-493.462,19.087],[-408.092,-38.334]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":90,"s":[{"i":[[0.539,9.693],[-0.235,-10.337],[13.68,-18.117],[6.861,0.702],[-0.386,-2.545],[-10.701,12.331]],"o":[[-0.539,-9.693],[0.27,11.903],[-16.969,22.473],[-1.637,4.424],[25.501,-0.115],[30.835,-35.534]],"v":[[-401.894,-141.08],[-421.574,-127.303],[-447.908,-82.673],[-494.997,-41.374],[-511.502,-18.405],[-438.9,-72.699]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[-0.594,9.69],[-2.162,-6.375],[27.037,-27.328],[6.732,1.496],[-0.087,-2.572],[-12.299,10.738]],"o":[[0.594,-9.69],[1.89,5.573],[-19.805,20.019],[-2.141,4.203],[25.341,2.857],[21.425,-18.707]],"v":[[-359.473,-106.921],[-378.033,-98.978],[-421.172,-43.599],[-478.725,0.898],[-493.462,19.087],[-408.092,-38.334]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":127,"s":[{"i":[[0.539,9.693],[-0.235,-10.337],[13.68,-18.117],[6.861,0.702],[-0.386,-2.545],[-10.701,12.331]],"o":[[-0.539,-9.693],[0.27,11.903],[-16.969,22.473],[-1.637,4.424],[25.501,-0.115],[30.835,-35.534]],"v":[[-401.894,-141.08],[-421.574,-127.303],[-447.908,-82.673],[-494.997,-41.374],[-511.502,-18.405],[-438.9,-72.699]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[-0.482,9.696],[-1.982,-16.188],[26.719,-27.639],[6.749,1.418],[-0.117,-2.571],[-12.174,10.88]],"o":[[0.482,-9.696],[0.955,7.799],[-19.572,20.247],[-2.092,4.228],[25.372,2.563],[21.208,-18.954]],"v":[[-350.373,-95.934],[-368.673,-86.67],[-412.623,-30.691],[-469.656,14.47],[-484.182,32.827],[-399.483,-25.577]],"c":true}]},{"t":140,"s":[{"i":[[-0.594,9.69],[-2.162,-6.375],[27.037,-27.328],[6.732,1.496],[-0.087,-2.572],[-12.299,10.738]],"o":[[0.594,-9.69],[1.89,5.573],[-19.805,20.019],[-2.141,4.203],[25.341,2.857],[21.425,-18.707]],"v":[[-359.473,-106.921],[-378.033,-98.978],[-421.172,-43.599],[-478.725,0.898],[-493.462,19.087],[-408.092,-38.334]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.968627510819,0.686274509804,0.298039215686,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":0,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-386.81,-67.361],[-444.252,-11.067],[-370.077,1.778],[-344.753,-26.683],[-366.164,-47.392]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[42.015,-33.446],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-360.125,-68.093],[-430.076,9.527],[-370.077,1.778],[-344.753,-26.683],[-351.612,-42.344]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":20,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-386.81,-67.361],[-444.252,-11.067],[-370.077,1.778],[-344.753,-26.683],[-366.164,-47.392]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-386.81,-67.361],[-444.252,-11.067],[-370.077,1.778],[-344.753,-26.683],[-366.164,-47.392]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":90,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[9.421,11.493]],"o":[[0,0],[35.704,-7.147],[0,0],[0,0],[-11.322,-13.812]],"v":[[-413.045,-99.491],[-464.338,-43.913],[-370.077,1.778],[-344.753,-26.683],[-374.835,-53.888]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-386.81,-67.361],[-444.252,-11.067],[-370.077,1.778],[-344.753,-26.683],[-366.164,-47.392]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":127,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[9.421,11.493]],"o":[[0,0],[35.704,-7.147],[0,0],[0,0],[-11.322,-13.812]],"v":[[-413.045,-99.491],[-464.338,-43.913],[-370.077,1.778],[-344.753,-26.683],[-374.835,-53.888]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[0,0],[42.015,-33.446],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-360.125,-68.093],[-430.076,9.527],[-370.077,1.778],[-344.753,-26.683],[-351.612,-42.344]],"c":true}]},{"t":140,"s":[{"i":[[0,0],[22.391,-21.509],[0,0],[0,0],[0,0]],"o":[[0,0],[30.859,-3.082],[0,0],[0,0],[0,0]],"v":[[-386.81,-67.361],[-444.252,-11.067],[-370.077,1.778],[-344.753,-26.683],[-366.164,-47.392]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.323529411765,0.27827761781,0.052018453561,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.823529471603,0.352941176471,0.050980395897,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 8","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[7.666,-6.309],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-14.207,11.692],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-360.022,-57.921],[-378.299,-78.231],[-418.613,-31.96],[-468.378,2.162],[-406.845,44.241]],"c":false}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[23.58,-23.432],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-20.619,20.489],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-354.772,-64.363],[-355.989,-84.75],[-410.62,-20.16],[-465.652,22.555],[-406.845,44.241]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[7.666,-6.309],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-14.207,11.692],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-360.022,-57.921],[-378.299,-78.231],[-418.613,-31.96],[-468.378,2.162],[-406.845,44.241]],"c":false}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[7.666,-6.309],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-14.207,11.692],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-360.022,-57.921],[-378.299,-78.231],[-418.613,-31.96],[-468.378,2.162],[-406.845,44.241]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":90,"s":[{"i":[[0,0],[15.553,19.764],[0,0],[12.118,-14.09],[0,0],[-11.54,-40.252]],"o":[[0,0],[-13.468,-17.114],[0,0],[-11.998,13.95],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-372.086,-70.141],[-412.43,-119.634],[-446.837,-70.665],[-486.516,-29.077],[-406.845,44.241]],"c":false}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[7.666,-6.309],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-14.207,11.692],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-360.022,-57.921],[-378.299,-78.231],[-418.613,-31.96],[-468.378,2.162],[-406.845,44.241]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":127,"s":[{"i":[[0,0],[15.553,19.764],[0,0],[12.118,-14.09],[0,0],[-11.54,-40.252]],"o":[[0,0],[-13.468,-17.114],[0,0],[-11.998,13.95],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-372.086,-70.141],[-412.43,-119.634],[-446.837,-70.665],[-486.516,-29.077],[-406.845,44.241]],"c":false}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[0,0],[0,0],[0,0],[23.58,-23.432],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-20.619,20.489],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-354.772,-64.363],[-355.989,-84.75],[-410.62,-20.16],[-465.652,22.555],[-406.845,44.241]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[7.666,-6.309],[0,0],[-6.54,-6.669]],"o":[[0,0],[0,0],[0,0],[-14.207,11.692],[0,0],[-0.5,25.981]],"v":[[-342.435,-38.379],[-360.022,-57.921],[-378.299,-78.231],[-418.613,-31.96],[-468.378,2.162],[-406.845,44.241]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.89019613827,0.576470588235,0.145098039216,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[-0.129,-0.214]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":106,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":107,"s":[100]},{"t":140,"s":[100]}]},"r":{"a":1,"k":[{"i":{"x":[0.639],"y":[0.573]},"o":{"x":[0.303],"y":[0.255]},"t":0,"s":[23.646]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.306],"y":[-0.573]},"t":25,"s":[12.164]},{"i":{"x":[0.567],"y":[0.554]},"o":{"x":[0.222],"y":[0.316]},"t":107,"s":[36]},{"t":140,"s":[23.646]}]},"p":{"a":1,"k":[{"i":{"x":0.659,"y":0.508},"o":{"x":0.313,"y":0.525},"t":0,"s":[131.03,42.694,0],"to":[-4.104,0.885,0],"ti":[3.276,-1.605,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.311,"y":0.098},"t":25,"s":[119.731,46.825,0],"to":[-3.482,1.706,0],"ti":[1.417,15.583,0]},{"i":{"x":0.629,"y":0.821},"o":{"x":0.167,"y":0.167},"t":107,"s":[149.06,224.761,0],"to":[-0.156,-1.713,0],"ti":[2.702,15.596,0]},{"i":{"x":0.616,"y":0.869},"o":{"x":0.27,"y":0.611},"t":121,"s":[159.593,69.39,0],"to":[-4.472,-25.812,0],"ti":[11.342,-2.445,0]},{"t":140,"s":[131.03,42.694,0]}]},"a":{"a":0,"k":[-115.44,-124.739,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0.134,-17.141],[5.969,16.793],[6.445,2.659],[-13.578,-6.969],[0.598,13.239]],"o":[[-0.051,6.47],[-4.835,-13.602],[-6.445,-2.659],[12.125,6.223],[7.261,11.211]],"v":[[-101.805,-113.922],[-94.337,-131.962],[-116.225,-165.334],[-134.886,-135.349],[-113.433,-152.275]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.096282211005,0.1580806059,0.621568627451,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"note02","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":106,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":107,"s":[100]},{"t":140,"s":[100]}]},"r":{"a":1,"k":[{"i":{"x":[0.601],"y":[1]},"o":{"x":[0.284],"y":[0.66]},"t":0,"s":[3.683]},{"i":{"x":[0.835],"y":[0.873]},"o":{"x":[0.167],"y":[0]},"t":20,"s":[3]},{"i":{"x":[0.081],"y":[0.804]},"o":{"x":[0.227],"y":[0.149]},"t":107,"s":[16]},{"t":140,"s":[3.683]}]},"p":{"a":1,"k":[{"i":{"x":0.601,"y":1},"o":{"x":0.284,"y":0.661},"t":0,"s":[4.189,-191.206,0],"to":[-0.389,-0.593,0],"ti":[2.189,2.293,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":20,"s":[-1.46,-198.272,0],"to":[-9.269,-9.713,0],"ti":[0.931,9.807,0]},{"i":{"x":0.068,"y":0.806},"o":{"x":0.167,"y":0.167},"t":107,"s":[-38.127,-36.606,0],"to":[-3.785,-39.879,0],"ti":[30.631,46.636,0]},{"t":140,"s":[4.189,-191.206,0]}]},"a":{"a":0,"k":[-336.204,-176.426,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[1.214,1.342],[9.5,-3.75],[2.75,-3.5],[-4.75,-0.75],[-9.5,12.75],[-1.5,-17],[4.75,11.25],[10.25,-20.25],[-9.39,13.452]],"o":[[-2.792,-3.085],[-9.5,3.75],[-2.914,3.709],[4.75,0.75],[-3.5,7.5],[4.25,21.5],[-3.437,-8.14],[3.25,-21.75],[8.398,-12.031]],"v":[[-314.75,-228.5],[-346.75,-197.25],[-361,-196.5],[-354.5,-179.75],[-333.75,-196.5],[-349.25,-144.25],[-312.25,-160.5],[-340.75,-153.75],[-314.398,-214.969]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.096282211005,0.1580806059,0.621568627451,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"note01","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":73,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":74,"s":[100]},{"t":140,"s":[100]}]},"r":{"a":1,"k":[{"i":{"x":[0.652],"y":[1]},"o":{"x":[0.318],"y":[0.645]},"t":0,"s":[-17.396]},{"i":{"x":[0.833],"y":[0.855]},"o":{"x":[0.167],"y":[0]},"t":7,"s":[-17]},{"i":{"x":[0.599],"y":[0.928]},"o":{"x":[0.333],"y":[0.027]},"t":74,"s":[0]},{"i":{"x":[0.423],"y":[0.826]},"o":{"x":[0.166],"y":[-0.274]},"t":85,"s":[-38.285]},{"t":140,"s":[-17.396]}]},"p":{"a":1,"k":[{"i":{"x":0.652,"y":1},"o":{"x":0.318,"y":0.659},"t":0,"s":[-155.632,-194.514,0],"to":[-0.04,0.008,0],"ti":[0.374,-0.067,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":7,"s":[-156.795,-194.295,0],"to":[-0.044,0.008,0],"ti":[-2.157,20.979,0]},{"i":{"x":0.599,"y":0.684},"o":{"x":0.167,"y":0.167},"t":74,"s":[-14.923,-85.89,0],"to":[3.324,-32.33,0],"ti":[14.71,16.588,0]},{"i":{"x":0.423,"y":0.924},"o":{"x":0.166,"y":0.48},"t":85,"s":[-15.576,-192.003,0],"to":[-32.667,-36.838,0],"ti":[26.277,-5.154,0]},{"t":140,"s":[-155.632,-194.514,0]}]},"a":{"a":0,"k":[-423.223,-176.877,0]},"s":{"a":1,"k":[{"i":{"x":[0.652,0.652,0.652],"y":[1,1,1]},"o":{"x":[0.318,0.318,0.318],"y":[0.654,0.654,0.142]},"t":0,"s":[99.902,99.902,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.859,0.859,3.557]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":7,"s":[100,100,100]},{"i":{"x":[0.26,0.26,0.26],"y":[0.922,0.922,-2.114]},"o":{"x":[0.177,0.177,0.177],"y":[0.173,0.173,6.901]},"t":74,"s":[81.917,81.917,100]},{"t":140,"s":[99.902,99.902,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[2.034,-1.09],[11.164,-4.618],[-2.558,-2.826],[0.5,-25.5],[15.438,9.924],[0,0],[4.5,13.25],[-10.356,7.027],[-1.25,-13.25],[13.5,7.25],[1.25,-5.5],[-1.739,27.432]],"o":[[-39.12,21.105],[-3.381,1.398],[6.301,6.962],[7.75,19.25],[-14,-9],[0,0],[18.423,-0.246],[-0.25,33.75],[15,21.75],[-13.047,-7.007],[-2.282,-17.343],[0.166,-2.621]],"v":[[-400.244,-206.073],[-469.293,-191.605],[-471.051,-181.212],[-457.5,-117],[-426.25,-145.75],[-449.544,-133.2],[-453.211,-165.129],[-401.04,-177.495],[-397.748,-125.001],[-368.248,-156.251],[-391.498,-139.001],[-387.617,-207.208]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":7,"s":[{"i":[[2.05,-1.07],[11.164,-4.618],[-2.558,-2.826],[0.5,-25.5],[15.438,9.924],[0,0],[4.5,13.25],[-10.542,7.013],[-1.25,-13.25],[13.5,7.25],[1.25,-5.5],[-1.739,27.432]],"o":[[-39.232,20.474],[-3.381,1.398],[6.301,6.962],[7.75,19.25],[-14,-9],[0,0],[18.122,0.138],[-0.25,33.75],[15,21.75],[-13.047,-7.007],[-2.282,-17.343],[0.166,-2.621]],"v":[[-400.222,-205.389],[-469.293,-191.605],[-471.051,-181.212],[-457.5,-117],[-426.25,-145.75],[-449.544,-133.2],[-453.211,-165.129],[-400.947,-176.988],[-397.653,-124.394],[-368.153,-155.644],[-391.403,-138.394],[-387.521,-206.601]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[{"i":[[2.304,-0.191],[11.164,-4.618],[-2.558,-2.826],[0.5,-25.5],[15.438,9.924],[0,0],[4.5,13.25],[-4,7.5],[-1.25,-13.25],[13.5,7.25],[1.25,-5.5],[-1.739,27.432]],"o":[[-33.439,2.773],[-3.381,1.398],[6.301,6.962],[7.75,19.25],[-14,-9],[0,0],[33.691,0.85],[-0.25,33.75],[15,21.75],[-13.047,-7.007],[-2.282,-17.343],[0.166,-2.621]],"v":[[-396.253,-192.145],[-469.293,-191.605],[-471.051,-181.212],[-457.5,-117],[-426.25,-145.75],[-449.544,-133.2],[-453.211,-165.129],[-399.459,-157.531],[-396.253,-108.449],[-366.753,-139.699],[-390.003,-122.449],[-386.121,-190.656]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":95,"s":[{"i":[[1.474,-1.781],[11.164,-4.618],[-2.558,-2.826],[0.5,-25.5],[15.438,9.924],[0,0],[4.5,13.25],[-4,7.5],[-1.25,-13.25],[13.5,7.25],[1.25,-5.5],[-1.739,27.432]],"o":[[-35.302,42.646],[-3.381,1.398],[6.301,6.962],[7.75,19.25],[-14,-9],[0,0],[28.711,-13.382],[-0.25,33.75],[15,21.75],[-13.047,-7.007],[-2.282,-17.343],[0.166,-2.621]],"v":[[-401,-229.446],[-469.293,-191.605],[-471.051,-181.212],[-457.5,-117],[-426.25,-145.75],[-449.544,-133.2],[-453.211,-165.129],[-404.206,-194.832],[-401,-145.75],[-371.5,-177],[-394.75,-159.75],[-390.868,-227.957]],"c":true}]},{"t":140,"s":[{"i":[[2.034,-1.09],[11.164,-4.618],[-2.558,-2.826],[0.5,-25.5],[15.438,9.924],[0,0],[4.5,13.25],[-10.356,7.027],[-1.25,-13.25],[13.5,7.25],[1.25,-5.5],[-1.739,27.432]],"o":[[-39.12,21.105],[-3.381,1.398],[6.301,6.962],[7.75,19.25],[-14,-9],[0,0],[18.423,-0.246],[-0.25,33.75],[15,21.75],[-13.047,-7.007],[-2.282,-17.343],[0.166,-2.621]],"v":[[-400.244,-206.073],[-469.293,-191.605],[-471.051,-181.212],[-457.5,-117],[-426.25,-145.75],[-449.544,-133.2],[-453.211,-165.129],[-401.04,-177.495],[-397.748,-125.001],[-368.248,-156.251],[-391.498,-139.001],[-387.617,-207.208]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.096282211005,0.1580806059,0.621568627451,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"SAX","parent":1,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":0,"s":[31]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[25]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":50,"s":[-93]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[-58]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.69],"y":[0]},"t":90,"s":[50]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.169],"y":[0.047]},"t":103,"s":[18]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.69],"y":[0]},"t":127,"s":[50]},{"t":140,"s":[31]}]},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[108.443,20.327,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.487,"y":0.681},"o":{"x":0.167,"y":0.167},"t":15,"s":[89.443,46.327,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.472,"y":1},"o":{"x":0.183,"y":0.534},"t":25,"s":[31.784,29.439,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[9.443,52.327,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[44.685,42.911,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.42,"y":0},"t":77,"s":[114.943,15.827,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":90,"s":[112.443,16.827,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[105.943,21.327,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.42,"y":0},"t":127,"s":[117.943,14.327,0],"to":[0,0,0],"ti":[0,0,0]},{"t":140,"s":[108.443,20.327,0]}]},"a":{"a":0,"k":[-262.496,-44.339,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-392.595,156.896],[-393.479,140.819]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.850980451995,0.670588235294,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 11","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-392.854,123.337],[-392.197,52.143]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.852733537263,0.670588235294,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 10","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0,0],[1.442,-13.935],[0,0],[0,0],[0.906,-13.353],[0,0],[0,0],[0.544,-15.216],[0,0]],"o":[[0,0],[-1.226,11.845],[0,0],[0,0],[-1.06,15.62],[0,0],[0,0],[-0.573,16.025],[0,0]],"v":[[-408.443,62.546],[-424.424,73.944],[-410.624,91.169],[-411.42,99.035],[-426.019,111.383],[-411.08,129.121],[-410.321,136.699],[-425.093,150.286],[-409.082,166.861]],"c":true}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[1.442,-13.935],[0,0],[0,0],[0.906,-13.353],[0,0],[0,0],[0.544,-15.216],[0,0]],"o":[[0,0],[-1.226,11.845],[0,0],[0,0],[-1.06,15.62],[0,0],[0,0],[-0.573,16.025],[0,0]],"v":[[-408.443,62.546],[-424.424,73.944],[-416.649,88.014],[-418.394,98.246],[-438.777,103.415],[-424.878,124.011],[-426.634,134.172],[-457.296,140.225],[-426.562,165.134]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":31,"s":[{"i":[[0,0],[1.442,-13.935],[0,0],[0,0],[0.906,-13.353],[0,0],[0,0],[0.544,-15.216],[0,0]],"o":[[0,0],[-1.226,11.845],[0,0],[0,0],[-1.06,15.62],[0,0],[0,0],[-0.573,16.025],[0,0]],"v":[[-408.443,62.546],[-424.424,73.944],[-410.624,91.169],[-411.42,99.035],[-426.019,111.383],[-411.08,129.121],[-410.321,136.699],[-425.093,150.286],[-409.082,166.861]],"c":true}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[1.442,-13.935],[0,0],[0,0],[0.906,-13.353],[0,0],[0,0],[0.544,-15.216],[0,0]],"o":[[0,0],[-1.226,11.845],[0,0],[0,0],[-1.06,15.62],[0,0],[0,0],[-0.573,16.025],[0,0]],"v":[[-408.443,62.546],[-424.424,73.944],[-410.624,91.169],[-411.42,99.035],[-426.019,111.383],[-411.08,129.121],[-410.321,136.699],[-425.093,150.286],[-409.082,166.861]],"c":true}]},{"t":80,"s":[{"i":[[0,0],[1.442,-13.935],[0,0],[0,0],[0.906,-13.353],[0,0],[0,0],[0.544,-15.216],[0,0]],"o":[[0,0],[-1.226,11.845],[0,0],[0,0],[-1.06,15.62],[0,0],[0,0],[-0.573,16.025],[0,0]],"v":[[-408.443,62.546],[-424.424,73.944],[-410.624,91.169],[-411.42,99.035],[-426.019,111.383],[-411.08,129.121],[-410.321,136.699],[-425.093,150.286],[-409.082,166.861]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.323529411765,0.27827761781,0.052018453561,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 7","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-268.448,-51.632],[-266.781,-38.273]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":6},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882352941,0.262745098039,0.050980395897,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-10.75,-2.915],[-6.131,0.732],[-0.062,5.701],[-6.406,-0.765],[9.241,4.32],[5.742,-4.6],[4.377,-2.418],[3.679,5.108],[0,0]],"o":[[0,0],[10.75,2.915],[8.574,-1.023],[0.094,-8.62],[6.892,1.619],[-14.382,-3.957],[-5.742,4.6],[-4.377,2.418],[-1.614,-2.241],[0,0]],"v":[[-336.919,-84.068],[-321.224,-7.056],[-282.194,-10.337],[-275.563,-31.422],[-255.714,-35.818],[-256.477,-51.676],[-289.863,-52.655],[-293.868,-33.02],[-308.482,-34.658],[-316.569,-84.456]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.89019613827,0.576470588235,0.145098039216,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[27.502,-6.053],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-337.969,200.978],[-295.5,150.671],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[-1.343,-27.248],[26.786,0.467],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[39.761,10.001],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-345.903,194.416],[-295.5,150.671],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[0,0],[-1.343,-27.248],[26.786,0.467],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[39.761,10.001],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-345.897,192.18],[-295.5,150.671],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":31,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[27.502,-6.053],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-337.969,200.978],[-295.5,150.671],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[27.502,-6.053],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-337.969,200.978],[-295.5,150.671],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[34.076,-2.034],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-332.493,199.296],[-293.195,152.875],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[39.3,0.378],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-330.966,203.844],[-291.779,154.228],[-318.909,-70.597]],"c":true}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":75,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[23.831,54.385],[0,0]],"o":[[0,0],[1.343,27.248],[39.3,0.378],[-1.75,-20.099],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-330.966,203.844],[-290.791,161.122],[-318.909,-70.597]],"c":true}]},{"t":80,"s":[{"i":[[0,0],[-1.343,-27.248],[18.578,-10.834],[-1.88,14.176],[0,0]],"o":[[0,0],[1.343,27.248],[27.502,-6.053],[1.88,-14.176],[0,0]],"v":[[-327.788,-74.26],[-305.437,143.022],[-337.969,200.978],[-295.5,150.671],[-318.909,-70.597]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.323529411765,0.27827761781,0.052018453561,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.823529411765,0.351334575578,0.051672434339,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 9","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[-3.445,-22.685],[-26.96,2.898],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[3.681,24.243],[30.907,-3.322],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-349.257,209.498],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[7.827,-21.569],[-39.628,-3.186],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[-8.67,23.891],[39.9,3.208],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-426.376,162.642],[-346.044,200.433],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[7.326,-21.619],[-37.888,-9.954],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[-8.121,23.906],[41.187,10.821],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-423.681,143.2],[-347.296,197.479],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":24,"s":[{"i":[[6.825,-21.668],[-38.502,-2.646],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[-7.572,23.922],[39.1,2.628],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-422.844,149.691],[-346.33,201.239],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":31,"s":[{"i":[[-3.445,-22.685],[-26.96,2.898],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[3.681,24.243],[30.907,-3.322],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-349.257,209.498],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[-3.445,-22.685],[-26.96,2.898],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[3.681,24.243],[30.907,-3.322],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-349.257,209.498],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[-3.135,-22.73],[-26.96,2.898],[-1.075,25.085],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[2.258,16.37],[30.907,-3.322],[1.12,-23.38],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-333.134,205.433],[-288.448,153.794],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[-3.445,-22.685],[-26.96,2.898],[0.707,26.338],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[3.681,24.243],[30.907,-3.322],[-0.63,-23.497],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-327.065,209.619],[-286.656,154.056],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":75,"s":[{"i":[[-3.445,-22.685],[-27.063,-1.685],[0.707,26.338],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[3.681,24.243],[82.005,5.106],[-0.63,-23.497],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-327.065,209.619],[-286.656,154.056],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]},{"t":80,"s":[{"i":[[-3.445,-22.685],[-26.96,2.898],[-3.747,23.205],[0.717,9.899],[-1.591,-25.112],[0,0]],"o":[[3.681,24.243],[30.907,-3.322],[3.747,-23.205],[-1.551,-21.421],[1.302,20.55],[0,0]],"v":[[-406.473,162.277],[-349.257,209.498],[-291.136,153.401],[-318.927,-103.718],[-356.793,-99.365],[-335.803,128.38]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.201355982762,0.00595155744,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.89019613827,0.574625172335,0.145098039216,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-8.412,-51.33],[0,0],[0,0]],"o":[[0,0],[11.023,67.257],[0,0],[0,0]],"v":[[-371.841,-7.953],[-357.492,79.812],[-334.582,125.42],[-348.787,-32.425]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.323529411765,0.27827761781,0.052018453561,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.823529471603,0.352941176471,0.050980395897,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 8","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[6.734,4.412],[6.855,-7.036],[3.385,-3.928],[-1.421,-9.646],[0,0]],"o":[[-5.785,4.934],[-3.6,3.695],[2.146,3.233],[18.512,-15.79],[0,0]],"v":[[-346.562,-30.098],[-396.522,19.754],[-408.603,35.907],[-401.907,47.197],[-342.696,-19.296]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.89019613827,0.576470588235,0.145098039216,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 12","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0,0],[0,0],[11.957,-16.427],[-6.274,-16.527]],"o":[[0,0],[0,0],[-0.5,25.981],[6.274,16.527]],"v":[[-318.696,163.484],[-338.274,-35.337],[-406.845,44.241],[-404.712,167.017]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[0,0],[11.957,-16.427],[3.186,-18.493]],"o":[[0,0],[0,0],[-0.5,25.981],[-3.001,17.421]],"v":[[-318.696,163.484],[-338.274,-35.337],[-406.845,44.241],[-426.707,164.958]],"c":false}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":24,"s":[{"i":[[0,0],[0,0],[11.957,-16.427],[3.186,-18.493]],"o":[[0,0],[0,0],[-0.5,25.981],[-3.001,17.421]],"v":[[-318.696,163.484],[-338.274,-35.337],[-406.845,44.241],[-422.565,150.777]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":31,"s":[{"i":[[0,0],[0,0],[11.957,-16.427],[-6.274,-16.527]],"o":[[0,0],[0,0],[-0.5,25.981],[6.274,16.527]],"v":[[-318.696,163.484],[-338.274,-35.337],[-406.845,44.241],[-404.712,167.017]],"c":false}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[0,0],[11.957,-16.427],[-6.274,-16.527]],"o":[[0,0],[0,0],[-0.5,25.981],[6.274,16.527]],"v":[[-318.696,163.484],[-338.274,-35.337],[-406.845,44.241],[-404.712,167.017]],"c":false}]},{"t":80,"s":[{"i":[[0,0],[0,0],[11.957,-16.427],[-6.274,-16.527]],"o":[[0,0],[0,0],[-0.5,25.981],[6.274,16.527]],"v":[[-318.696,163.484],[-338.274,-35.337],[-406.845,44.241],[-404.712,167.017]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.89019613827,0.576470588235,0.145098039216,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"SAX 2","parent":8,"sr":1,"ks":{"p":{"a":0,"k":[-262.496,-44.339,0]},"a":{"a":0,"k":[-262.496,-44.339,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":0,"s":[{"i":[[7.306,3.381],[22.61,-21.63],[-6.109,-4.337],[-12.627,11.627]],"o":[[-8.441,-3.905],[-16.23,15.527],[6.109,4.337],[16.281,-14.992]],"v":[[-378.554,-97.912],[-437.64,-54.419],[-478.688,0.53],[-419.451,-44.76]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[7.408,3.151],[21.926,-22.323],[-6.241,-4.145],[-12.259,12.014]],"o":[[-8.558,-3.641],[-15.739,16.024],[6.241,4.145],[15.806,-15.491]],"v":[[-367.309,-86.43],[-425.013,-41.118],[-464.329,15.082],[-406.532,-32.03]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":20,"s":[{"i":[[7.306,3.381],[22.61,-21.63],[-6.109,-4.337],[-12.627,11.627]],"o":[[-8.441,-3.905],[-16.23,15.527],[6.109,4.337],[16.281,-14.992]],"v":[[-378.554,-97.912],[-437.64,-54.419],[-478.688,0.53],[-419.451,-44.76]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[7.306,3.381],[22.61,-21.63],[-6.109,-4.337],[-12.627,11.627]],"o":[[-8.441,-3.905],[-16.23,15.527],[6.109,4.337],[16.281,-14.992]],"v":[[-378.554,-97.912],[-437.64,-54.419],[-478.688,0.53],[-419.451,-44.76]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":90,"s":[{"i":[[3.47,2.622],[13.752,-16.158],[-4.654,-3.506],[-12.364,16.159]],"o":[[-5.192,-3.923],[-17.846,20.969],[5.984,4.507],[15.622,-20.417]],"v":[[-419.66,-128.266],[-465.763,-90.522],[-495.236,-41.2],[-447.291,-79.645]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[7.306,3.381],[22.61,-21.63],[-6.109,-4.337],[-12.627,11.627]],"o":[[-8.441,-3.905],[-16.23,15.527],[6.109,4.337],[16.281,-14.992]],"v":[[-378.554,-97.912],[-437.64,-54.419],[-478.688,0.53],[-419.451,-44.76]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":127,"s":[{"i":[[3.47,2.622],[13.752,-16.158],[-4.654,-3.506],[-12.364,16.159]],"o":[[-5.192,-3.923],[-17.846,20.969],[5.984,4.507],[15.622,-20.417]],"v":[[-419.66,-128.266],[-465.763,-90.522],[-495.236,-41.2],[-447.291,-79.645]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[7.408,3.151],[21.926,-22.323],[-6.241,-4.145],[-12.259,12.014]],"o":[[-8.558,-3.641],[-15.739,16.024],[6.241,4.145],[15.806,-15.491]],"v":[[-367.309,-86.43],[-425.013,-41.118],[-464.329,15.082],[-406.532,-32.03]],"c":true}]},{"t":140,"s":[{"i":[[7.306,3.381],[22.61,-21.63],[-6.109,-4.337],[-12.627,11.627]],"o":[[-8.441,-3.905],[-16.23,15.527],[6.109,4.337],[16.281,-14.992]],"v":[[-378.554,-97.912],[-437.64,-54.419],[-478.688,0.53],[-419.451,-44.76]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.323529411765,0.27827761781,0.052018453561,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.823529471603,0.352941176471,0.050980395897,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":0,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-361.466,-115.063],[-447.479,-62.407],[-496.484,19.5],[-409.491,-36.553]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-351.353,-107.199],[-432.202,-53.285],[-488.236,36.01],[-395.827,-25.379]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":20,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-361.466,-115.063],[-447.479,-62.407],[-496.484,19.5],[-409.491,-36.553]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-361.466,-115.063],[-447.479,-62.407],[-496.484,19.5],[-409.491,-36.553]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":90,"s":[{"i":[[8.63,7.507],[33.023,-35.499],[-12.528,-10.787],[-37.049,37.888]],"o":[[-10.224,-8.894],[-31.505,33.866],[10.909,9.393],[36.757,-37.59]],"v":[[-398.938,-150.525],[-474.867,-98.22],[-518.15,-13.796],[-438.393,-69.393]],"c":true}]},{"i":{"x":0.43,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-361.466,-115.063],[-447.479,-62.407],[-496.484,19.5],[-409.491,-36.553]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.52,"y":0},"t":127,"s":[{"i":[[8.63,7.507],[33.023,-35.499],[-12.528,-10.787],[-37.049,37.888]],"o":[[-10.224,-8.894],[-31.505,33.866],[10.909,9.393],[36.757,-37.59]],"v":[[-398.938,-150.525],[-474.867,-98.22],[-518.15,-13.796],[-438.393,-69.393]],"c":true}]},{"i":{"x":0.44,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-351.353,-107.199],[-432.202,-53.285],[-488.236,36.01],[-395.827,-25.379]],"c":true}]},{"t":140,"s":[{"i":[[12.419,5.421],[36,-36.167],[-12.528,-10.787],[-37.049,37.888]],"o":[[-12.419,-5.421],[-35.472,35.635],[10.909,9.393],[36.757,-37.59]],"v":[[-361.466,-115.063],[-447.479,-62.407],[-496.484,19.5],[-409.491,-36.553]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.505882352941,0.20000001496,0.007843137255,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.96862745098,0.685536343444,0.296286010742,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"NOSE","parent":18,"sr":1,"ks":{"r":{"a":0,"k":-37},"p":{"a":0,"k":[-194.987,-3.612,0]},"a":{"a":0,"k":[-190.284,-17.401,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[14.5,10.5],[-13.52,-9.152]],"o":[[-15.033,-10.886],[16.25,11]],"v":[[-184,-26],[-197.25,-9]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.956862804936,0.815686334348,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"EYE01","parent":18,"sr":1,"ks":{"r":{"a":0,"k":-37},"p":{"a":0,"k":[-243.653,3.199,0]},"a":{"a":0,"k":[-233.25,-41.25,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.45,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-220,-54.5],[-224.5,-45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":15,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-220,-54.5],[-224.5,-45.5]],"c":false}]},{"i":{"x":0.5,"y":1},"o":{"x":0.167,"y":0.167},"t":31,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-227.346,-48.79],[-231.358,-29.876]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":50,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248.295,-4.576],[-240.376,-16.72]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-240.805,-23.481],[-233.947,-29.608]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.45,"y":0},"t":78,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-220,-54.5],[-224.5,-45.5]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-220,-54.5],[-224.5,-45.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.45,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-240,-58.5],[-238,-45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":15,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-240,-58.5],[-238,-45.5]],"c":false}]},{"i":{"x":0.5,"y":1},"o":{"x":0.167,"y":0.167},"t":31,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-243.774,-50.136],[-243.461,-33.557]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":50,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-263.085,-22.293],[-248.748,-25.662]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-256.154,-25.802],[-244.623,-33.975]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.45,"y":0},"t":78,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-240,-58.5],[-238,-45.5]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-240,-58.5],[-238,-45.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.45,"y":0},"t":0,"s":[{"i":[[0,0],[11.5,0.5],[0,0],[0,0]],"o":[[0,0],[-11.5,-0.5],[0,0],[0,0]],"v":[[-211,-24],[-229.5,-43],[-250,-36.5],[-255.5,-48.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":15,"s":[{"i":[[0,0],[11.5,0.5],[0,0],[0,0]],"o":[[0,0],[-11.5,-0.5],[0,0],[0,0]],"v":[[-211,-24],[-229.5,-43],[-250,-36.5],[-255.5,-48.5]],"c":false}]},{"i":{"x":0.5,"y":1},"o":{"x":0.167,"y":0.167},"t":31,"s":[{"i":[[0,0],[12.403,2.397],[0,0],[0,0]],"o":[[0,0],[-11.217,-1.988],[0,0],[0,0]],"v":[[-211.48,-22.907],[-234.166,-31.253],[-252.227,-37.119],[-257.271,-48.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":50,"s":[{"i":[[0,0],[13.476,4.649],[0,0],[0,0]],"o":[[0,0],[-10.882,-3.754],[0,0],[0,0]],"v":[[-212.049,-21.61],[-239.708,-17.302],[-254.871,-37.854],[-266.979,-37.064]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[12.488,2.574],[0,0],[0,0]],"o":[[0,0],[-11.191,-2.127],[0,0],[0,0]],"v":[[-213.897,-20.205],[-234.604,-30.151],[-252.436,-37.177],[-265.78,-32.256]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.45,"y":0},"t":78,"s":[{"i":[[0,0],[11.5,0.5],[0,0],[0,0]],"o":[[0,0],[-11.5,-0.5],[0,0],[0,0]],"v":[[-211,-24],[-229.5,-43],[-250,-36.5],[-255.5,-48.5]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[11.5,0.5],[0,0],[0,0]],"o":[[0,0],[-11.5,-0.5],[0,0],[0,0]],"v":[[-211,-24],[-229.5,-43],[-250,-36.5],[-255.5,-48.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"EYE02","parent":18,"sr":1,"ks":{"r":{"a":0,"k":-37},"p":{"a":0,"k":[-140.265,-2.085,0]},"a":{"a":0,"k":[-147.5,16.75,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.45,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-144,6],[-131.5,1]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":15,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-144,6],[-131.5,1]],"c":false}]},{"i":{"x":0.5,"y":1},"o":{"x":0.167,"y":0.167},"t":31,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-153.795,16.917],[-141.004,16.161]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":50,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-167.324,31.85],[-171.057,48.282]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-157.844,19.854],[-162.977,33.489]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.45,"y":0},"t":78,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-144,6],[-131.5,1]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-144,6],[-131.5,1]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.45,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-140,18.5],[-125.5,17]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":15,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-140,18.5],[-125.5,17]],"c":false}]},{"i":{"x":0.5,"y":1},"o":{"x":0.167,"y":0.167},"t":31,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-145.48,26.651],[-130.61,27.039]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":50,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-155.68,37.481],[-153.826,52.815]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-147.84,27.99],[-151.826,42.665]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.45,"y":0},"t":78,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-140,18.5],[-125.5,17]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-140,18.5],[-125.5,17]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.45,"y":0},"t":0,"s":[{"i":[[0,0],[-8.5,-9.5],[0,0],[0,0]],"o":[[0,0],[8.5,9.5],[0,0],[0,0]],"v":[[-170.5,-4],[-146.5,5.5],[-139,32],[-124.5,37.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":15,"s":[{"i":[[0,0],[-8.5,-9.5],[0,0],[0,0]],"o":[[0,0],[8.5,9.5],[0,0],[0,0]],"v":[[-170.5,-4],[-146.5,5.5],[-139,32],[-124.5,37.5]],"c":false}]},{"i":{"x":0.5,"y":1},"o":{"x":0.167,"y":0.167},"t":31,"s":[{"i":[[0,0],[-8.5,-9.5],[0,0],[0,0]],"o":[[0,0],[8.5,9.5],[0,0],[0,0]],"v":[[-173.951,-0.279],[-156.359,15.458],[-140.54,34.229],[-125.205,39.89]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.49,"y":0},"t":50,"s":[{"i":[[0,0],[-8.5,-9.5],[0,0],[0,0]],"o":[[0,0],[8.5,9.5],[0,0],[0,0]],"v":[[-178.048,4.141],[-168.066,27.284],[-142.369,36.876],[-135.514,49.245]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[-10.094,-9.422],[0,0],[0,0]],"o":[[0,0],[9.229,8.73],[0,0],[0,0]],"v":[[-179.506,4.532],[-157.6,18.675],[-140.685,34.438],[-137.41,48.587]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.45,"y":0},"t":78,"s":[{"i":[[0,0],[-8.5,-9.5],[0,0],[0,0]],"o":[[0,0],[8.5,9.5],[0,0],[0,0]],"v":[[-170.5,-4],[-146.5,5.5],[-139,32],[-124.5,37.5]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[-8.5,-9.5],[0,0],[0,0]],"o":[[0,0],[8.5,9.5],[0,0],[0,0]],"v":[[-170.5,-4],[-146.5,5.5],[-139,32],[-124.5,37.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"neckle","parent":14,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":0,"s":[2]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-8.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":50,"s":[-19]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[-8.5]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.69],"y":[0]},"t":90,"s":[2]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":103,"s":[2]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.43],"y":[0]},"t":127,"s":[2]},{"t":140,"s":[2]}]},"p":{"a":0,"k":[-237.347,93.788,0]},"a":{"a":0,"k":[-240.005,91.093,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[2.755,14.413],[-2.455,-13.566]],"o":[[-2.33,-12.192],[2.79,15.412]],"v":[[-249.593,50.114],[-269.967,54.077]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.658665615905,0.283944612391,0.73137254902,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.974163758521,0.843137254902,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[11.003,-4.604],[-11.334,3.218]],"o":[[-10.419,4.36],[12.867,-3.653]],"v":[[-197.482,85.535],[-191.144,104.559]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.658665615905,0.283944612391,0.73137254902,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.974163758521,0.843137254902,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[12.5,-8.25],[-11.555,9.002]],"o":[[-9.78,6.455],[9.079,-7.073]],"v":[[-211,70.5],[-198.206,85.749]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.658665615905,0.283944612391,0.73137254902,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.974163758521,0.843137254902,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[6.75,-11.25],[-6.643,11.811]],"o":[[-7.213,12.022],[6.75,-12]],"v":[[-229,59.5],[-211.5,69.75]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.658665615905,0.283944612391,0.73137254902,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.974163758521,0.843137254902,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[6.393,-10.388],[-5.739,13.894]],"o":[[-6.824,11.088],[5.061,-12.253]],"v":[[-247.724,50.595],[-228.812,58.852]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.658665615905,0.283944612391,0.73137254902,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.974163758521,0.843137254902,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"BODY","parent":1,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-19]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":50,"s":[-74]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[-37]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.69],"y":[0]},"t":90,"s":[7]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":103,"s":[-3]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.43],"y":[0]},"t":127,"s":[7]},{"t":140,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[90.3,150.487,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[107.796,171.989,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[130.3,91.987,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.26,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[110.3,156.237,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.69,"y":0},"t":90,"s":[69.8,142.487,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[90.3,150.487,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.54,"y":1},"o":{"x":0.43,"y":0},"t":127,"s":[69.8,142.487,0],"to":[0,0,0],"ti":[0,0,0]},{"t":140,"s":[90.3,150.487,0]}]},"a":{"a":0,"k":[-248.5,139.5,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[{"i":[[9,8.5],[15,-27.5],[0,0],[-17,-6],[0,0]],"o":[[-0.5,26],[18,-13.5],[0,0],[-2.5,-12.5],[0,0]],"v":[[-220,77.5],[-250.5,184.5],[-226.5,166],[-196,181],[-208,95.5]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[9,8.5],[15,-27.5],[0,0],[-9.972,-8.35],[0,0]],"o":[[-0.5,26],[18,-13.5],[0,0],[2.592,-15.658],[0,0]],"v":[[-220,77.5],[-256.809,182.857],[-228.259,168.038],[-203.936,184.084],[-208,95.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[{"i":[[9,8.5],[15,-27.5],[0,0],[-8.404,-9.18],[0,0]],"o":[[-0.5,26],[26.547,-3.333],[0,0],[26.93,-47.4],[0,0]],"v":[[-220,77.5],[-285.311,183.227],[-249.542,177.262],[-233.114,196.612],[-208,95.5]],"c":true}]},{"i":{"x":0.26,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[9,8.5],[15,-27.5],[0,0],[-12.702,-7.59],[0,0]],"o":[[-0.5,26],[22.273,-8.417],[0,0],[12.215,-29.95],[0,0]],"v":[[-220,77.5],[-267.905,183.864],[-238.021,171.631],[-214.557,188.806],[-208,95.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.69,"y":0},"t":90,"s":[{"i":[[9,8.5],[6.196,-35.44],[0,0],[-19.294,3.287],[0,0]],"o":[[-0.5,26],[12.343,-10],[0,0],[-11.623,-16.294],[0,0]],"v":[[-220,77.5],[-241.019,187.87],[-224.332,167.245],[-186.318,177.796],[-208,95.5]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[9,8.5],[15,-27.5],[0,0],[-17,-6],[0,0]],"o":[[-0.5,26],[18,-13.5],[0,0],[-2.5,-12.5],[0,0]],"v":[[-220,77.5],[-250.5,184.5],[-226.5,166],[-196,181],[-208,95.5]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.43,"y":0},"t":127,"s":[{"i":[[9,8.5],[6.196,-35.44],[0,0],[-19.294,3.287],[0,0]],"o":[[-0.5,26],[12.343,-10],[0,0],[-11.623,-16.294],[0,0]],"v":[[-220,77.5],[-241.019,187.87],[-224.332,167.245],[-186.318,177.796],[-208,95.5]],"c":true}]},{"t":140,"s":[{"i":[[9,8.5],[15,-27.5],[0,0],[-17,-6],[0,0]],"o":[[-0.5,26],[18,-13.5],[0,0],[-2.5,-12.5],[0,0]],"v":[[-220,77.5],[-250.5,184.5],[-226.5,166],[-196,181],[-208,95.5]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.625490196078,0.114840443929,0.06377546273,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.800000059838,0.133333333333,0.066666666667,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[{"i":[[18.984,-11.783],[27,-41],[0,0],[-5,-8],[-8,4.5],[-4,-3.5],[-7,7.5],[-11,0.5],[3.5,27.5]],"o":[[-14.5,9],[16.5,0],[0,0],[15,-5],[6.5,13],[13.5,-7.5],[12,8],[-4.5,-21],[-2.22,-17.445]],"v":[[-257.5,70.5],[-331,159.5],[-302,155.5],[-295.5,178],[-266,165],[-251.5,189.5],[-227.5,170.5],[-190.5,187.5],[-205,90]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[18.984,-11.783],[38.785,-28.18],[0,0],[3.578,-12.206],[-10.29,3.003],[-4,-3.5],[-8.884,7.542],[-8.947,-3.085],[3.5,27.5]],"o":[[-14.5,9],[18.671,7.199],[0,0],[15.187,-2.921],[4.497,10.739],[13.519,-9.892],[12,8],[-0.544,-21.344],[-2.22,-17.445]],"v":[[-257.5,70.5],[-332.432,128.336],[-307.207,138.371],[-311.853,168.668],[-279.075,159.969],[-262.599,192.553],[-230.988,171.414],[-198.274,190.111],[-205,90]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[{"i":[[18.984,-11.783],[41.952,-6.447],[0,0],[10.416,-12.428],[-11.711,2.579],[3.012,-11.903],[-13.562,5.444],[-3.069,-5.877],[3.5,27.5]],"o":[[-14.5,9],[12.304,17.139],[0,0],[13.596,9.547],[-1.469,11.086],[19.643,-1.067],[8.285,12.703],[15.944,-17.57],[-2.22,-17.445]],"v":[[-257.5,70.5],[-334.52,99.286],[-308.115,122.507],[-324.783,148.969],[-285.464,157.64],[-291.175,187.223],[-250.552,181.186],[-234.113,202.989],[-205,90]],"c":true}]},{"i":{"x":0.26,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[18.984,-11.783],[34.476,-23.723],[0,0],[2.708,-10.214],[-9.868,-0.004],[-0.494,-7.701],[-10.281,6.472],[-7.034,-2.688],[3.5,27.5]],"o":[[-14.5,9],[14.402,8.569],[0,0],[14.298,2.273],[4.305,11.928],[16.571,-4.284],[10.143,10.352],[5.722,-19.285],[-2.22,-17.445]],"v":[[-257.5,70.5],[-335.527,114.787],[-306.134,128.801],[-318.33,158.566],[-281.635,162.507],[-271.337,188.361],[-239.026,175.843],[-212.306,195.244],[-205,90]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.69,"y":0},"t":90,"s":[{"i":[[18.984,-11.783],[27,-41],[0,0],[-5,-8],[-8,4.5],[-4,-3.5],[-7,7.5],[-10.62,9.656],[3.5,27.5]],"o":[[-14.5,9],[15.111,-2.658],[0,0],[15,-5],[6.5,13],[7.065,-8.687],[12,8],[-10.193,-15.585],[-2.22,-17.445]],"v":[[-257.5,70.5],[-323.939,163.671],[-294.504,159.113],[-289.371,182.789],[-260.672,167.368],[-241.34,194.298],[-222.233,172.372],[-180.496,178.715],[-205,90]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[18.984,-11.783],[27,-41],[0,0],[-5,-8],[-8,4.5],[-4,-3.5],[-7,7.5],[-11,0.5],[3.5,27.5]],"o":[[-14.5,9],[16.5,0],[0,0],[15,-5],[6.5,13],[13.5,-7.5],[12,8],[-4.5,-21],[-2.22,-17.445]],"v":[[-257.5,70.5],[-331,159.5],[-302,155.5],[-295.5,178],[-266,165],[-251.5,189.5],[-227.5,170.5],[-190.5,187.5],[-205,90]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.43,"y":0},"t":127,"s":[{"i":[[18.984,-11.783],[27,-41],[0,0],[-5,-8],[-8,4.5],[-4,-3.5],[-7,7.5],[-10.62,9.656],[3.5,27.5]],"o":[[-14.5,9],[15.111,-2.658],[0,0],[15,-5],[6.5,13],[7.065,-8.687],[12,8],[-10.193,-15.585],[-2.22,-17.445]],"v":[[-257.5,70.5],[-323.939,163.671],[-294.504,159.113],[-289.371,182.789],[-260.672,167.368],[-241.34,194.298],[-222.233,172.372],[-180.496,178.715],[-205,90]],"c":true}]},{"t":140,"s":[{"i":[[18.984,-11.783],[27,-41],[0,0],[-5,-8],[-8,4.5],[-4,-3.5],[-7,7.5],[-11,0.5],[3.5,27.5]],"o":[[-14.5,9],[16.5,0],[0,0],[15,-5],[6.5,13],[13.5,-7.5],[12,8],[-4.5,-21],[-2.22,-17.445]],"v":[[-257.5,70.5],[-331,159.5],[-302,155.5],[-295.5,178],[-266,165],[-251.5,189.5],[-227.5,170.5],[-190.5,187.5],[-205,90]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.625490196078,0.114840443929,0.06377546273,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.933333393172,0.266666666667,0.20000001496,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"LEG01","parent":1,"sr":1,"ks":{"p":{"a":0,"k":[32.297,228.51,0]},"a":{"a":0,"k":[-306.503,217.523,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[{"i":[[0,0],[-0.5,-9],[-5.5,1],[-1.342,2.683],[0,0]],"o":[[0,0],[0.5,9],[5.5,-1],[0,-1],[0,0]],"v":[[-303,136.5],[-335,177.5],[-297.5,217.5],[-306,182],[-278,158]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0,0],[-0.5,-9],[-5.5,1],[-1.342,2.683],[0,0]],"o":[[0,0],[0.5,9],[5.5,-1],[0,-1],[0,0]],"v":[[-290,156],[-318.5,191.5],[-297.5,217.5],[-289,195.5],[-263.5,174.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[{"i":[[0,0],[9.481,-17.751],[-5.5,1],[-15.125,18.5],[0,0]],"o":[[0,0],[-5.875,11],[5.5,-1],[19.125,-19],[0,0]],"v":[[-262,127.25],[-275,173.75],[-297.5,217.5],[-252.375,188],[-215,125]],"c":true}]},{"i":{"x":0.26,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[4.49,-13.376],[-5.5,1],[-8.313,11.5],[0,0]],"o":[[0,0],[-2.688,10],[5.5,-1],[11.188,-7],[0,0]],"v":[[-280.5,143.375],[-307,182.125],[-297.5,217.5],[-278.688,194.5],[-251.5,156.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.69,"y":0},"t":90,"s":[{"i":[[0,0],[-0.5,-9],[-5.5,1],[-1.342,2.683],[0,0]],"o":[[0,0],[0.5,9],[5.5,-1],[0,-1],[0,0]],"v":[[-317.5,129.5],[-339.5,177.5],[-297.5,217.5],[-307.5,179.5],[-284,144.5]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[0,0],[-0.5,-9],[-5.5,1],[-1.342,2.683],[0,0]],"o":[[0,0],[0.5,9],[5.5,-1],[0,-1],[0,0]],"v":[[-303,136.5],[-335,177.5],[-297.5,217.5],[-306,182],[-278,158]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.43,"y":0},"t":127,"s":[{"i":[[0,0],[-0.5,-9],[-5.5,1],[-1.342,2.683],[0,0]],"o":[[0,0],[0.5,9],[5.5,-1],[0,-1],[0,0]],"v":[[-317.5,129.5],[-339.5,177.5],[-297.5,217.5],[-307.5,179.5],[-284,144.5]],"c":true}]},{"t":140,"s":[{"i":[[0,0],[-0.5,-9],[-5.5,1],[-1.342,2.683],[0,0]],"o":[[0,0],[0.5,9],[5.5,-1],[0,-1],[0,0]],"v":[[-303,136.5],[-335,177.5],[-297.5,217.5],[-306,182],[-278,158]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784373564,0.694117647059,0.152941176471,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"LEG02","parent":1,"sr":1,"ks":{"p":{"a":0,"k":[150.788,232.005,0]},"a":{"a":0,"k":[-188.012,221.017,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":0,"s":[{"i":[[3.5,12.5],[0,0],[-11.809,-13.213],[-5.206,0.651]],"o":[[-14,0],[0,0],[14.389,16.1],[8,-1]],"v":[[-204,159.5],[-248.5,162.5],[-226.73,187.84],[-190,221]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[18,22.5],[0,0],[-1.047,-3.858],[-4.885,0.611]],"o":[[-14,0],[0,0],[1.494,5.502],[8,-1]],"v":[[-175,174.5],[-212.5,168.5],[-194.494,196.498],[-190,221]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.42,"y":0},"t":50,"s":[{"i":[[1.5,35],[0,0],[-1.279,-13.377],[-4.885,0.611]],"o":[[-14,0],[0,0],[1.244,13.002],[8,-1]],"v":[[-160.75,91.5],[-197,108.25],[-197.494,161.248],[-190,221]],"c":true}]},{"i":{"x":0.26,"y":1},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[2.5,23.75],[0,0],[-4.888,-11.544],[-5.045,0.631]],"o":[[-14,0],[0,0],[5.612,14.456],[8,-1]],"v":[[-180.375,155.5],[-210.25,156.875],[-206.612,193.544],[-190,221]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.69,"y":0},"t":90,"s":[{"i":[[8.5,24.5],[0,0],[-9.571,-9.841],[-5.206,0.651]],"o":[[-14,0],[0,0],[15.23,15.66],[8,-1]],"v":[[-220,156],[-263,156],[-233.73,192.34],[-190,221]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[3.5,12.5],[0,0],[-11.809,-13.213],[-5.206,0.651]],"o":[[-14,0],[0,0],[14.389,16.1],[8,-1]],"v":[[-204,159.5],[-248.5,162.5],[-226.73,187.84],[-190,221]],"c":true}]},{"i":{"x":0.54,"y":1},"o":{"x":0.43,"y":0},"t":127,"s":[{"i":[[8.5,24.5],[0,0],[-9.571,-9.841],[-5.206,0.651]],"o":[[-14,0],[0,0],[15.23,15.66],[8,-1]],"v":[[-220,156],[-263,156],[-233.73,192.34],[-190,221]],"c":true}]},{"t":140,"s":[{"i":[[3.5,12.5],[0,0],[-11.809,-13.213],[-5.206,0.651]],"o":[[-14,0],[0,0],[14.389,16.1],[8,-1]],"v":[[-204,159.5],[-248.5,162.5],[-226.73,187.84],[-190,221]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784373564,0.694117647059,0.152941176471,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"HAND01","parent":14,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.48],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[19]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":54,"s":[-8]},{"i":{"x":[0.52],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":71,"s":[-8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.48],"y":[0]},"t":90,"s":[8]},{"i":{"x":[0.52],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":103,"s":[0]},{"i":{"x":[0.52],"y":[1]},"o":{"x":[0.48],"y":[0]},"t":127,"s":[8]},{"t":140,"s":[0]}]},"p":{"a":0,"k":[-255.072,98.795,0]},"a":{"a":0,"k":[-255.072,98.795,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[4.5,-7.5],[-8,-1.5],[4.712,6.951]],"o":[[-4.5,7.5],[9.25,-4.5],[-6.288,-5.299]],"v":[[-329.25,84.5],[-266,116],[-256.962,91.049]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784373564,0.694117647059,0.152941176471,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"STAR","parent":14,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":0,"s":[37]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[44.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":50,"s":[14]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[25.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.69],"y":[0]},"t":90,"s":[49]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":103,"s":[37]},{"i":{"x":[0.54],"y":[1]},"o":{"x":[0.43],"y":[0]},"t":127,"s":[49]},{"t":140,"s":[37]}]},"p":{"a":0,"k":[-223.048,55.886,0]},"a":{"a":0,"k":[-177.048,74.636,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.53,"y":1},"o":{"x":0.48,"y":0},"t":0,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-169.735,-142.969]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":15,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-162.084,-145.51]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[0,0],[-48.726,14.642]],"o":[[0,0],[20.425,-6.138]],"v":[[-213.192,-91.742],[-134.129,-150.285]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":54,"s":[{"i":[[0,0],[-14.235,11.179]],"o":[[0,0],[12.159,-9.549]],"v":[[-213.723,-114.484],[-190.154,-151.294]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":71,"s":[{"i":[[0,0],[-7.356,9.68]],"o":[[0,0],[10.917,-14.366]],"v":[[-223.009,-107.222],[-225.64,-137.167]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":86,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-169.735,-142.969]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.47,"y":0},"t":93,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-162.084,-145.51]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":100,"s":[{"i":[[0,0],[-39.059,24.296]],"o":[[0,0],[19.201,-11.943]],"v":[[-210.95,-88.535],[-155.775,-143.23]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-169.735,-142.969]],"c":false}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-212.861,-95.301],[-183.587,-146.521]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.48,"y":0},"t":127,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-169.735,-142.969]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[0,0],[-39.059,24.296]],"o":[[0,0],[19.201,-11.943]],"v":[[-210.95,-88.535],[-155.775,-143.23]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[-25.053,20.757]],"o":[[0,0],[17.412,-14.427]],"v":[[-208.325,-94.481],[-169.735,-142.969]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.53,"y":1},"o":{"x":0.48,"y":0},"t":0,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-281.119,-47.766],[-287.085,-76.452],[-274.289,-78.581]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":15,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-279.446,-53.549],[-283.78,-85.784],[-267.263,-83.594]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[0,0],[-7.397,11.277],[0,0]],"o":[[0,0],[5.72,4.859],[0,0]],"v":[[-281.643,-68.332],[-271.012,-101.25],[-252.985,-86.802]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":54,"s":[{"i":[[0,0],[-10.002,2.711],[0,0]],"o":[[0,0],[10.572,-1.867],[0,0]],"v":[[-300.271,-55.145],[-299.592,-70.79],[-264.508,-75.034]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":71,"s":[{"i":[[0,0],[-1.585,4.099],[0,0]],"o":[[0,0],[22.471,-16.932],[0,0]],"v":[[-314.495,-47.565],[-321.711,-59.919],[-267.613,-73.803]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":86,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-281.119,-47.766],[-287.085,-76.452],[-274.289,-78.581]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.47,"y":0},"t":93,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-279.446,-53.549],[-283.78,-85.784],[-267.263,-83.594]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":100,"s":[{"i":[[0,0],[-4.414,8.709],[0,0]],"o":[[0,0],[7.091,1.218],[0,0]],"v":[[-276.654,-52.064],[-268.74,-93.186],[-252.674,-87.118]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-281.119,-47.766],[-287.085,-76.452],[-274.289,-78.581]],"c":false}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-289.677,-44.023],[-297.194,-71.446],[-277.923,-74.333]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.48,"y":0},"t":127,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-281.119,-47.766],[-287.085,-76.452],[-274.289,-78.581]],"c":false}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[0,0],[-4.414,8.709],[0,0]],"o":[[0,0],[7.091,1.218],[0,0]],"v":[[-276.654,-52.064],[-268.74,-93.186],[-252.674,-87.118]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[-0.77,13.102],[0,0]],"o":[[0,0],[6.745,-2.265],[0,0]],"v":[[-281.119,-47.766],[-287.085,-76.452],[-274.289,-78.581]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.53,"y":1},"o":{"x":0.48,"y":0},"t":0,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-62.992,-57.811],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-64.424,81.943],[-86.646,6.343],[-38.362,-66.667]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":15,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-55.361,-59.235],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-54.822,70.715],[-86.646,6.343],[-31.891,-65.876]],"c":true}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[16.173,-4.721],[-13.559,-11.473],[26.577,-17.442],[-0.838,-25.328],[25.003,1.193],[-18.308,7.285],[16.205,24.413],[-21.525,11.866]],"o":[[16.558,3.701],[-7.728,14.816],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[2.125,-7.064],[17.359,-28.944],[-18.563,-23.795]],"v":[[-109.607,-87.425],[-57.826,-65.106],[-112.884,-10.198],[-79.351,65.573],[-158.315,79.288],[-42.844,82.437],[-86.378,4.844],[-16.295,-62.572]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":54,"s":[{"i":[[6.674,-9.814],[-12.32,-2.805],[23.664,-24.893],[-3.246,-17.686],[25.003,1.193],[-22.293,13.436],[16.205,24.413],[0.804,27.895]],"o":[[21.218,-10.802],[-4.797,19.374],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[-3.637,-10.521],[20.906,-19.198],[-28.926,-8.675]],"v":[[-113.356,-86.23],[-69.092,-91.947],[-112.884,-10.198],[-80.265,59.044],[-159.619,76.697],[-52.832,65.341],[-85.151,-2.01],[-51.942,-107.171]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":71,"s":[{"i":[[12.562,-27.805],[-9.136,-10.182],[24.947,-21.611],[-2.743,-22.887],[25.003,1.193],[-19.084,29.592],[27.308,-1.699],[11.781,43.315]],"o":[[17.118,-14.557],[0.011,20.585],[17.346,10.396],[-9.783,7.372],[13.677,11.919],[-1.099,-8.998],[20.906,-19.198],[-22.746,-9.56]],"v":[[-111.195,-85.973],[-71.193,-90.812],[-108.542,-1.689],[-73.486,44.055],[-159.044,77.838],[-44.199,43.379],[-86.123,5.46],[-50.117,-111.703]],"c":true}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":86,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-62.992,-57.811],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-64.424,81.943],[-86.646,6.343],[-38.362,-66.667]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.47,"y":0},"t":93,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-55.361,-59.235],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-59.989,79.911],[-86.646,6.343],[-31.891,-65.876]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":100,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[31.595,-11.098],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[22.763,-16.59],[-16.315,-19.308]],"v":[[-106.962,-87.984],[-47.419,-51.608],[-108.351,-7.608],[-87.265,66.795],[-158.029,79.855],[-57.025,87.989],[-84.905,7.448],[-22.57,-56.8]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-62.992,-57.811],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-64.424,81.943],[-86.646,6.343],[-38.362,-66.667]],"c":true}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[22.339,26.512],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-65.887,-67.704],[-112.884,-10.198],[-88.973,56.652],[-158.029,79.855],[-60.457,70.976],[-86.646,6.343],[-44.386,-77.236]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.48,"y":0},"t":127,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-62.992,-57.811],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-64.424,81.943],[-86.646,6.343],[-38.362,-66.667]],"c":true}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[31.595,-11.098],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[22.763,-16.59],[-16.315,-19.308]],"v":[[-106.962,-87.984],[-47.419,-51.608],[-108.351,-7.608],[-87.265,66.795],[-158.029,79.855],[-49.381,79.34],[-84.905,7.448],[-22.57,-56.8]],"c":true}]},{"t":140,"s":[{"i":[[13.068,-2.221],[-13.83,-13.369],[27.214,-15.812],[-0.311,-26.999],[25.003,1.193],[-17.437,5.94],[16.205,24.413],[-6.645,24.415]],"o":[[15.538,6.873],[-8.369,13.819],[10.277,11.977],[-9.783,7.372],[13.677,11.919],[3.386,-6.308],[20.906,-19.198],[-11.825,-11.123]],"v":[[-107.377,-85.519],[-62.992,-57.811],[-112.884,-10.198],[-91.417,63.154],[-158.029,79.855],[-64.424,81.943],[-86.646,6.343],[-38.362,-66.667]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784373564,0.694117647059,0.152941176471,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.53,"y":1},"o":{"x":0.48,"y":0},"t":0,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-4.345,-42.79],[14.671,-14.276],[-29.722,-17.713],[-11.107,-20.963],[-30.999,15.569],[-20.067,-9.88]],"v":[[-303.549,-89.61],[-279.904,11.153],[-295.987,114.982],[-176.671,77.797],[-57.974,84.325],[-80.633,7.584],[-33.516,-69.842],[-111.47,-91.614],[-162.329,-168.094],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":15,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-10.498,-35.615],[14.671,-14.276],[-29.722,-17.713],[-5.368,-24.954],[-30.999,15.569],[-24.73,-13.174]],"v":[[-296.246,-97.526],[-279.904,11.153],[-287.697,102.165],[-176.671,77.797],[-47.039,73.569],[-80.633,7.584],[-26.594,-69.266],[-111.47,-91.614],[-152.699,-168.81],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[11.312,6.325],[-7.995,-14.513],[-4.077,-27.954],[-32.568,19.675],[-52.358,21.471],[6.027,9.215],[-16.983,13.831],[19.39,-1.667],[14.757,5.079],[4.754,-17.926]],"o":[[-42.118,38.95],[-8.702,15.873],[30.583,-1.483],[13.107,4.071],[-10.935,-35.885],[12.184,-22.404],[-24.382,-33.228],[4.699,-26.758],[-60.966,-9.135],[-25.043,-15.544]],"v":[[-271.432,-117.463],[-279.904,11.153],[-288.121,105.365],[-176.671,77.797],[-37.322,81.782],[-80.313,6.159],[-11.44,-63.055],[-118.41,-92.688],[-113.65,-160.539],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.47,"y":0},"t":54,"s":[{"i":[[9.11,-10.954],[-12.741,-9.391],[-4.077,-27.954],[-32.568,19.675],[-51.025,27.179],[6.028,9.215],[7.733,23.481],[18.988,-15.536],[14.757,5.079],[-0.546,-24.027]],"o":[[-0.455,30.434],[-7.607,18.107],[30.583,-1.483],[13.107,4.071],[-12.935,-37.119],[14.908,-19.432],[-28.4,-7.412],[-11.107,-20.963],[-34.462,19.642],[-31.804,-5.917]],"v":[[-320.175,-74.987],[-279.904,11.153],[-290.06,119.994],[-176.671,77.796],[-47.488,67.884],[-78.852,-0.358],[-48.364,-111.291],[-114.13,-92.808],[-184.28,-169.869],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":71,"s":[{"i":[[22.977,-35.562],[-10.65,-11.647],[-4.077,-27.954],[-32.568,19.675],[-37.864,50.33],[13.537,0.781],[20.561,46.989],[19.024,-30.626],[35.045,-2.607],[-12.449,-28.89]],"o":[[26.696,25.681],[-8.089,17.123],[30.583,-1.483],[13.107,4.071],[-10.921,-32.875],[14.823,-17.569],[-26.38,-11.033],[-19.203,-46.466],[-8.398,23.732],[-27.563,-7.349]],"v":[[-344.541,-58.956],[-279.904,11.153],[-305.874,99.333],[-176.671,77.797],[-39.819,43.71],[-79.495,2.512],[-47.41,-113.364],[-113.168,-92.377],[-237.737,-147.776],[-229.166,-86.178]],"c":true}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":86,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-4.345,-42.79],[14.671,-14.276],[-29.722,-17.713],[-11.107,-20.963],[-30.999,15.569],[-20.067,-9.88]],"v":[[-303.549,-89.61],[-279.904,11.153],[-295.987,114.982],[-176.671,77.797],[-57.974,84.325],[-80.633,7.584],[-33.516,-69.842],[-111.47,-91.614],[-162.329,-168.094],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.47,"y":0},"t":93,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-10.498,-35.615],[14.671,-14.276],[-29.722,-17.713],[-5.368,-24.954],[-30.999,15.569],[-24.73,-13.174]],"v":[[-296.246,-97.526],[-279.904,11.153],[-287.697,102.165],[-176.671,77.797],[-51.476,80.837],[-80.633,7.584],[-26.594,-69.266],[-111.47,-91.614],[-152.699,-168.81],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":100,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-50.781,14.002],[6.027,9.215],[0.76,20.718],[19.478,1.367],[13.634,12.022],[5.914,-16.592]],"o":[[-22.374,34.894],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[0.034,-37.77],[19.761,-11.037],[-23.537,-23.747],[-5.368,-24.954],[-42.465,6.721],[-14.829,-19.712]],"v":[[-276.531,-107.994],[-279.904,11.153],[-287.697,102.165],[-176.671,77.797],[-51.036,91.859],[-79.219,7.62],[-17.653,-59.139],[-112.486,-93.408],[-136.131,-166.625],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-4.345,-42.79],[14.671,-14.276],[-29.722,-17.713],[-11.107,-20.963],[-30.999,15.569],[-20.067,-9.88]],"v":[[-303.549,-89.61],[-279.904,11.153],[-295.987,114.982],[-176.671,77.797],[-57.974,84.325],[-80.633,7.584],[-33.516,-69.842],[-111.47,-91.614],[-162.329,-168.094],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.52,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.027,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[2.77,-22.277]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-4.345,-42.79],[14.671,-14.276],[-29.722,-17.713],[-11.107,-20.963],[-30.999,15.569],[-20.067,-9.88]],"v":[[-312.468,-82.35],[-279.904,11.153],[-301.596,107.299],[-176.671,77.797],[-54.322,72.971],[-80.633,7.584],[-41.264,-80.943],[-111.47,-91.614],[-179.959,-165.992],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.48,"y":0},"t":127,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-4.345,-42.79],[14.671,-14.276],[-29.722,-17.713],[-11.107,-20.963],[-30.999,15.569],[-20.067,-9.88]],"v":[[-303.549,-89.61],[-279.904,11.153],[-295.987,114.982],[-176.671,77.797],[-57.974,84.325],[-80.633,7.584],[-33.516,-69.842],[-111.47,-91.614],[-162.329,-168.094],[-230.17,-87.399]],"c":true}]},{"i":{"x":0.53,"y":1},"o":{"x":0.167,"y":0.167},"t":134,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.027,9.215],[0.76,20.718],[19.478,1.367],[13.634,12.022],[5.914,-16.592]],"o":[[-22.374,34.894],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-10.498,-35.615],[19.761,-11.037],[-23.537,-23.747],[-5.368,-24.954],[-42.465,6.721],[-14.829,-19.712]],"v":[[-276.531,-107.994],[-279.904,11.153],[-287.697,102.165],[-176.671,77.797],[-42.251,80.056],[-79.219,7.62],[-17.653,-59.139],[-112.486,-93.408],[-136.131,-166.625],[-230.17,-87.399]],"c":true}]},{"t":140,"s":[{"i":[[10.059,-5.712],[-6.957,-15.634],[-4.077,-27.954],[-32.568,19.675],[-52.649,20.223],[6.028,9.215],[0.76,20.718],[19.478,1.367],[14.757,5.079],[5.914,-16.592]],"o":[[-6.465,29.934],[-8.941,15.384],[30.583,-1.483],[13.107,4.071],[-4.345,-42.79],[14.671,-14.276],[-29.722,-17.713],[-11.107,-20.963],[-30.999,15.569],[-20.067,-9.88]],"v":[[-303.549,-89.61],[-279.904,11.153],[-295.987,114.982],[-176.671,77.797],[-57.974,84.325],[-80.633,7.584],[-33.516,-69.842],[-111.47,-91.614],[-162.329,-168.094],[-230.17,-87.399]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0}]} \ No newline at end of file From f3a939eeca4ca66a3e9e6178fe53df12c57a4365 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 3 Apr 2026 20:47:23 +0100 Subject: [PATCH 106/369] refactor: rewrite teams endpoints --- backend/app/models/team.py | 9 +- backend/app/routes/teams.py | 198 +++++++++++---------------- backend/app/schemas/team.py | 50 +++---- backend/app/utils/routes/__init__.py | 1 + backend/app/utils/routes/teams.py | 106 ++++++++++++++ 5 files changed, 212 insertions(+), 152 deletions(-) create mode 100644 backend/app/utils/routes/teams.py diff --git a/backend/app/models/team.py b/backend/app/models/team.py index f3b0cb6..5a1c0bf 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,4 +1,4 @@ -from sqlalchemy import ForeignKey +from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base @@ -7,7 +7,8 @@ class Team(Base, PKMixin): __tablename__ = "teams" - + __table_args__ = (UniqueConstraint("contact_info", "tournament_id"),) + name: Mapped[str] = mapped_column(nullable=False, unique=True) team_email: Mapped[str] = mapped_column(nullable=False, unique=True) contact_info: Mapped[str] = mapped_column(nullable=False, unique=True) @@ -16,7 +17,9 @@ class Team(Base, PKMixin): ForeignKey("team_members.id", ondelete="CASCADE"), nullable=True ) - tournament: Mapped["Tournament"] = relationship(back_populates="teams", lazy="selectin") + tournament: Mapped["Tournament"] = relationship( + back_populates="teams", lazy="selectin" + ) members: Mapped[list["TeamMember"]] = relationship( back_populates="team", foreign_keys="TeamMember.team_id", diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py index fb8c374..b9c4468 100644 --- a/backend/app/routes/teams.py +++ b/backend/app/routes/teams.py @@ -1,165 +1,121 @@ +from datetime import datetime, timezone from fastapi import status, HTTPException from fastapi.routing import APIRouter -from sqlalchemy import select, update +from sqlalchemy import select, update, func +from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError + from app.dependencies import SessionDep -from app.models import Team, Tournament +from app.models import Team, TeamMember, Tournament from app.schemas import TeamModel, TeamUpdate +from app.utils.routes import ( + get_tournament, + check_registration_open, + get_team, + validate_team_registration, + create_team, +) router = APIRouter(prefix="/tournaments/{tournament_id}/teams", tags=["teams"]) -async def get_team(team_id: int, session: SessionDep) -> Team: - statement = select(Team).where(Team.id == team_id) - team = (await session.execute(statement)).scalar() - if not team: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Team not found!") - return team - - @router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) async def teams(tournament_id: int, session: SessionDep): - statement = select(Team).where(Team.tournament_id == tournament_id) - teams = await session.execute(statement) - return teams.scalars().all() + await get_tournament(tournament_id, session) + + statement = ( + select(Team) + .where(Team.tournament_id == tournament_id) + .options(selectinload(Team.members), selectinload(Team.captain)) + ) + + result = await session.execute(statement) + return result.scalars().all() @router.get("/{team_id}/", response_model=TeamModel, status_code=status.HTTP_200_OK) -async def team(team_id: int, session: SessionDep): - return await get_team(team_id, session) +async def team(team_id: int, tournament_id: int, session: SessionDep): + return await get_team(team_id, tournament_id, session) @router.post("/", response_model=TeamModel, status_code=status.HTTP_201_CREATED) -async def create_team(team_data: TeamModel, session: SessionDep): - new_team = Team(**team_data.model_dump()) - session.add(new_team) +async def create_team(tournament_id: int, team_data: TeamModel, session: SessionDep): + + tournament = await get_tournament(tournament_id, session) + check_registration_open(tournament) + + await validate_team_registration(tournament, team_data, session) try: + new_team = await create_team(tournament_id, team_data, session) await session.commit() - await session.refresh(new_team) - except IntegrityError: + except IntegrityError as e: await session.rollback() raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Team with this name, email or phone number already exists", + status.HTTP_400_BAD_REQUEST, detail=f"Registration failed: {str(e.orig)}" ) - return new_team + statement = ( + select(Team) + .where(Team.id == new_team.id) + .options(selectinload(Team.members), selectinload(Team.captain)) + ) + result = await session.execute(statement) + return result.scalar_one_or_none() @router.patch("/{team_id}/", response_model=TeamModel, status_code=status.HTTP_200_OK) -async def update_team(team_id: int, team_data: TeamUpdate, session: SessionDep): - update_data = team_data.model_dump(exclude_unset=True) +async def update_team( + team_id: int, tournament_id: int, team_data: TeamUpdate, session: SessionDep +): + tournament = await get_tournament(tournament_id, session) - if not update_data: + if datetime.now(timezone.utc) > tournament.reg_end.replace(tzinfo=timezone.utc): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No fields provided for update", + status_code=status.HTTP_403_FORBIDDEN, + detail="Editing is forbidden after registration ends", ) - try: - result = await session.execute( - update(Team).where(Team.id == team_id).values(**update_data).returning(Team) - ) - updated_team = result.scalar_one_or_none() + await get_team(team_id, tournament_id, session) + + update_data = team_data.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(400, "No fields provided for update") - if not updated_team: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Team not found" - ) + await session.execute( + update(Team) + .where(Team.id == team_id, Team.tournament_id == tournament_id) + .values(**update_data) + ) + try: await session.commit() - await session.refresh(updated_team) except IntegrityError: await session.rollback() - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Team with this name, email, or phone number already exists", - ) + raise HTTPException(400, "Update violates constraints") + + statement = ( + select(Team) + .where(Team.id == team_id) + .options(selectinload(Team.members), selectinload(Team.captain)) + ) - return updated_team + result = await session.execute(statement) + return result.scalar_one_or_none() @router.delete("/{team_id}/", status_code=status.HTTP_204_NO_CONTENT) -async def delete_team(team_id: int, session: SessionDep): - team = await get_team(team_id, session) +async def delete_team(team_id: int, tournament_id: int, session: SessionDep): + tournament = await get_tournament(tournament_id, session) - await session.delete(team) - await session.commit() + if datetime.now(timezone.utc) > tournament.reg_end.replace(tzinfo=timezone.utc): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Deletion is forbidden" + ) + team = await get_team(team_id, tournament_id, session) -# @router.get("/", response_model=list[TeamModel], status_code=status.HTTP_200_OK) -# async def tournament_participants( -# tournament_id: int, -# session: SessionDep, -# ): -# statement = select(Team).where(Team.tournament_id == tournament_id) -# teams = await session.execute(statement) -# return teams.scalars().all() - - -# @router.post( -# "/", -# response_model=TeamModel, -# status_code=status.HTTP_201_CREATED, -# ) -# async def register_team( -# team: TeamModel, -# session: SessionDep, -# ): -# if await session.scalar(select(Team).where(Team.name == team.name)): -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, detail="Team name already exists" -# ) - -# if await session.scalar(select(Team).where(Team.team_email == team.team_email)): -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, detail="Team email already exists" -# ) - -# if await session.scalar(select(Team).where(Team.contact_info == team.contact_info)): -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, -# detail="Contact info already exists", -# ) - -# new_team = Team(**team.model_dump()) - -# session.add(new_team) - -# try: -# await session.commit() -# await session.refresh(new_team) - -# except IntegrityError: -# await session.rollback() -# raise HTTPException( -# status_code=400, -# detail="Team violates unique constraints", -# ) - -# return new_team - - -# @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) -# async def leave_tournament( -# tournament_id: int, -# team_id: int, -# session: SessionDep, -# ): -# statement = select(Team).where( -# Team.id == team_id, -# Team.tournament_id == tournament_id, -# ) -# result = await session.execute(statement) -# team = result.scalar_one_or_none() - -# if not team: -# raise HTTPException( -# status.HTTP_404_NOT_FOUND, -# detail="Team not found in this tournament", -# ) - -# await session.delete(team) -# await session.commit() + await session.delete(team) + await session.commit() diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 73b7d33..33c4c0e 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -2,27 +2,29 @@ from pydantic_extra_types.phone_numbers import PhoneNumber -class TeamBase(BaseModel): - name: str = Field(..., description="Name of the team") - team_email: EmailStr = Field(..., description="Contact email") - contact_info: PhoneNumber = Field(..., description="Phone number") +class TeamMemberModel(BaseModel): + full_name: str = Field(..., min_length=3) + email: EmailStr = Field(..., description="Contact email") + telegram_username: str + educational_institution: str - @field_validator("team_email") + @field_validator("email") @classmethod def normalize_email(cls, value: EmailStr): return value.lower() -class TeamUpdate(TeamBase): - name: str | None = None - team_email: EmailStr | None = None - contact_info: PhoneNumber | None = None - captain_id: int | None = None +class TeamMemberUpdate(BaseModel): + full_name: str | None = Field(None, min_length=3) + email: EmailStr | None = Field(None, description="Contact email") + telegram_username: str | None = None + educational_institution: str | None = None -class TeamModel(TeamBase): - tournament_id: int = Field(..., gt=0) - captain_id: int = Field(..., gt=0) +class TeamBase(BaseModel): + name: str = Field(..., description="Name of the team") + team_email: EmailStr = Field(..., description="Contact email") + contact_info: PhoneNumber = Field(..., description="Phone number") @field_validator("team_email") @classmethod @@ -30,20 +32,12 @@ def normalize_email(cls, value: EmailStr): return value.lower() -class TeamMemberBase(BaseModel): - full_name: str = Field(..., min_length=3) - email: EmailStr = Field(..., description="Contact email") - telegram_username: str - educational_institution: str - - -class TeamMemberUpdate(TeamMemberBase): - full_name: str | None = Field(None, min_length=3) - email: EmailStr | None = Field(None, description="Contact email") - telegram_username: str | None = None - educational_institution: str | None = None - team_id: int | None = Field(None, gt=0) +class TeamUpdate(BaseModel): + name: str | None = None + team_email: EmailStr | None = None + contact_info: PhoneNumber | None = None -class TeamMemberModel(TeamMemberBase): - team_id: int = Field(..., gt=0) +class TeamModel(TeamBase): + captain: TeamMemberModel + members: list[TeamMemberModel] = Field(..., min_items=1) diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py index 65463de..55d82b6 100644 --- a/backend/app/utils/routes/__init__.py +++ b/backend/app/utils/routes/__init__.py @@ -1,2 +1,3 @@ from .dates_logic import validate_dates_on_create, validate_dates_on_update +from .teams import get_tournament, check_registration_open, get_team, validate_team_registration, create_team from .tournament_status import auto_update_tournament_status, get_status_by_name diff --git a/backend/app/utils/routes/teams.py b/backend/app/utils/routes/teams.py new file mode 100644 index 0000000..ae7ffc8 --- /dev/null +++ b/backend/app/utils/routes/teams.py @@ -0,0 +1,106 @@ +from datetime import datetime, timezone +from fastapi import status, HTTPException +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + + +from app.dependencies import SessionDep +from app.models import Team, TeamMember, Tournament +from app.schemas import TeamModel + + +async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: + tournament = await session.get(Tournament, tournament_id) + + if not tournament: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tournament not found", + ) + + return tournament + + +def check_registration_open(tournament: Tournament): + now = datetime.now(timezone.utc) + + reg_start = tournament.reg_start.replace(tzinfo=timezone.utc) + reg_end = tournament.reg_end.replace(tzinfo=timezone.utc) + + if not (reg_start <= now <= reg_end): + raise HTTPException( + status_code=400, + detail="Registration is closed", + ) + + +async def get_team(team_id: int, tournament_id: int, session: SessionDep) -> Team: + statement = ( + select(Team) + .where( + Team.id == team_id, + Team.tournament_id == tournament_id, + ) + .options( + selectinload(Team.members), + selectinload(Team.captain), + ) + ) + + result = await session.execute(statement) + team = result.scalar_one_or_none() + + if not team: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="Team not found!", + ) + + return team + + +async def validate_team_registration( + tournament: Tournament, team_data: TeamModel, session: AsyncSession +): + + count_stmt = select(func.count()).where(Team.tournament_id == tournament.id) + teams_count = (await session.execute(count_stmt)).scalar() + if teams_count >= tournament.max_team: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Tournament is full") + + all_emails = [team_data.captain.email] + [m.email for m in team_data.members] + if len(all_emails) != len(set(all_emails)): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "Emails must be unique inside team" + ) + + +async def create_team( + tournament_id: int, team_data: TeamModel, session: AsyncSession +) -> Team: + + new_team = Team( + name=team_data.name, + team_email=team_data.team_email, + contact_info=str(team_data.contact_info), + tournament_id=tournament_id, + ) + session.add(new_team) + await session.flush() + + captain = TeamMember( + **team_data.captain.model_dump(), + team_id=new_team.id, + ) + session.add(captain) + await session.flush() + + members = [ + TeamMember(**m.model_dump(), team_id=new_team.id) for m in team_data.members + ] + session.add_all(members) + + new_team.captain_id = captain.id + + return new_team From 805a7c90437cb5b3bb8dba256f0fe5be5551570b Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 3 Apr 2026 21:26:18 +0100 Subject: [PATCH 107/369] refactor: rewrite team-member's endpoints --- backend/app/routes/team_members.py | 114 +++++++++++++++++++-------- backend/app/routes/teams.py | 14 +++- backend/app/utils/routes/__init__.py | 8 +- backend/app/utils/routes/teams.py | 2 +- 4 files changed, 99 insertions(+), 39 deletions(-) diff --git a/backend/app/routes/team_members.py b/backend/app/routes/team_members.py index b1461ad..1bf16a3 100644 --- a/backend/app/routes/team_members.py +++ b/backend/app/routes/team_members.py @@ -6,13 +6,21 @@ from app.dependencies import SessionDep from app.models import TeamMember from app.schemas import TeamMemberModel, TeamMemberUpdate +from app.utils.routes import get_team, check_registration_open, get_tournament -router = APIRouter(prefix="/team-members", tags=["team-members"]) +router = APIRouter( + prefix="/tournaments/{tournament_id}/teams/{team_id}/members", tags=["team-members"] +) -async def get_team_member(member_id: int, session: SessionDep) -> TeamMember: - statement = select(TeamMember).where(TeamMember.id == member_id) - member = (await session.execute(statement)).scalar() +async def get_team_member( + member_id: int, team_id: int, session: SessionDep +) -> TeamMember: + statement = select(TeamMember).where( + TeamMember.id == member_id, TeamMember.team_id == team_id + ) + + member = (await session.execute(statement)).scalar_one_or_none() if not member: raise HTTPException( @@ -24,79 +32,119 @@ async def get_team_member(member_id: int, session: SessionDep) -> TeamMember: @router.get("/", response_model=list[TeamMemberModel], status_code=status.HTTP_200_OK) -async def team_members(session: SessionDep): - statement = select(TeamMember) +async def team_members(tournament_id: int, team_id: int, session: SessionDep): + await get_team(team_id, tournament_id, session) + + statement = select(TeamMember).where(TeamMember.team_id == team_id) result = await session.execute(statement) return result.scalars().all() -@router.get("/{member_id}/", response_model=TeamMemberModel, status_code=status.HTTP_200_OK) -async def team_member(member_id: int, session: SessionDep): - return await get_team_member(member_id, session) +@router.get( + "/{member_id}/", response_model=TeamMemberModel, status_code=status.HTTP_200_OK +) +async def team_member( + tournament_id: int, team_id: int, member_id: int, session: SessionDep +): + await get_team(team_id, tournament_id, session) + + return await get_team_member(member_id, team_id, session) @router.post("/", response_model=TeamMemberModel, status_code=status.HTTP_201_CREATED) -async def create_team_member(member_data: TeamMemberModel, session: SessionDep): - new_member = TeamMember(**member_data.model_dump()) +async def create_team_member( + tournament_id: int, team_id: int, member_data: TeamMemberModel, session: SessionDep +): + await get_team(team_id, tournament_id, session) + + new_member = TeamMember( + **member_data.model_dump(), + team_id=team_id, + ) + session.add(new_member) try: await session.commit() await session.refresh(new_member) + except IntegrityError: await session.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User with this email or telegram username already exists", + detail="Email or telegram already exists", ) return new_member -@router.patch("/{member_id}/", response_model=TeamMemberModel, status_code=status.HTTP_200_OK) +@router.patch( + "/{member_id}/", response_model=TeamMemberModel, status_code=status.HTTP_200_OK +) async def update_team_member( - member_id: int, member_data: TeamMemberUpdate, session: SessionDep + tournament_id: int, + team_id: int, + member_id: int, + member_data: TeamMemberUpdate, + session: SessionDep, ): + await get_team(team_id, tournament_id, session) + update_data = member_data.model_dump(exclude_unset=True) if not update_data: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No fields provided for update", + status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided" ) - try: - result = await session.execute( - update(TeamMember) - .where(TeamMember.id == member_id) - .values(**update_data) - .returning(TeamMember) + result = await session.execute( + select(TeamMember).where( + TeamMember.id == member_id, + TeamMember.team_id == team_id, ) + ) - updated_member = result.scalar_one_or_none() + member = result.scalar_one_or_none() - if not updated_member: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Team member not found", - ) + if not member: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Team member not found" + ) + await session.execute( + update(TeamMember) + .where( + TeamMember.id == member_id, + TeamMember.team_id == team_id, + ) + .values(**update_data) + ) + try: await session.commit() - await session.refresh(updated_member) + await session.refresh(member) except IntegrityError: await session.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Email or telegram username already exists", + detail="Email or telegram already exists", ) - return updated_member + return member @router.delete("/{member_id}/", status_code=status.HTTP_204_NO_CONTENT) -async def delete_team_member(member_id: int, session: SessionDep): - member = await get_team_member(member_id, session) +async def delete_team_member( + tournament_id: int, team_id: int, member_id: int, session: SessionDep +): + tournament = await get_tournament(tournament_id, session) + check_registration_open(tournament) + + team = await get_team(team_id, tournament_id, session) + member = await get_team_member(member_id, team_id, session) + + if member.id == team.captain_id: + raise HTTPException(400, "Cannot delete captain") await session.delete(member) await session.commit() diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py index b9c4468..f9609e8 100644 --- a/backend/app/routes/teams.py +++ b/backend/app/routes/teams.py @@ -1,12 +1,12 @@ from datetime import datetime, timezone from fastapi import status, HTTPException from fastapi.routing import APIRouter -from sqlalchemy import select, update, func +from sqlalchemy import select, update from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError from app.dependencies import SessionDep -from app.models import Team, TeamMember, Tournament +from app.models import Team from app.schemas import TeamModel, TeamUpdate from app.utils.routes import ( get_tournament, @@ -81,7 +81,10 @@ async def update_team( update_data = team_data.model_dump(exclude_unset=True) if not update_data: - raise HTTPException(400, "No fields provided for update") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update", + ) await session.execute( update(Team) @@ -94,7 +97,10 @@ async def update_team( except IntegrityError: await session.rollback() - raise HTTPException(400, "Update violates constraints") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Update violates constraints", + ) statement = ( select(Team) diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py index 55d82b6..51b0741 100644 --- a/backend/app/utils/routes/__init__.py +++ b/backend/app/utils/routes/__init__.py @@ -1,3 +1,9 @@ from .dates_logic import validate_dates_on_create, validate_dates_on_update -from .teams import get_tournament, check_registration_open, get_team, validate_team_registration, create_team +from .teams import ( + get_tournament, + check_registration_open, + get_team, + validate_team_registration, + create_team, +) from .tournament_status import auto_update_tournament_status, get_status_by_name diff --git a/backend/app/utils/routes/teams.py b/backend/app/utils/routes/teams.py index ae7ffc8..ea001ec 100644 --- a/backend/app/utils/routes/teams.py +++ b/backend/app/utils/routes/teams.py @@ -30,7 +30,7 @@ def check_registration_open(tournament: Tournament): if not (reg_start <= now <= reg_end): raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail="Registration is closed", ) From 02f3531302d36892c81f7cd032549c60d1f84cfe Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 4 Apr 2026 13:07:24 +0300 Subject: [PATCH 108/369] refactor: use name as role id and add description field to role --- backend/alembic/versions/e15113d4ca9c_.py | 124 ++++++++++++++++++++++ backend/alembic/versions/e24fb9138dcd_.py | 73 +++++++++++++ backend/app/models/role.py | 9 +- backend/app/models/role_request.py | 4 +- backend/app/models/user.py | 2 +- backend/app/routes/role_requests.py | 2 +- backend/app/schemas/option.py | 4 + backend/app/schemas/role.py | 11 +- backend/app/schemas/role_request.py | 2 +- backend/tests/factories.py | 2 + backend/tests/routes/test_role_request.py | 16 +-- backend/tests/test_models.py | 4 +- 12 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 backend/alembic/versions/e15113d4ca9c_.py create mode 100644 backend/alembic/versions/e24fb9138dcd_.py diff --git a/backend/alembic/versions/e15113d4ca9c_.py b/backend/alembic/versions/e15113d4ca9c_.py new file mode 100644 index 0000000..5a2e570 --- /dev/null +++ b/backend/alembic/versions/e15113d4ca9c_.py @@ -0,0 +1,124 @@ +"""empty message + +Revision ID: e15113d4ca9c +Revises: f734dfe1b30d +Create Date: 2026-04-04 12:47:44.040902 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e15113d4ca9c' +down_revision: Union[str, Sequence[str], None] = 'f734dfe1b30d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # role_requests: migrate FK from role_id (int) to role_name (str) + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.add_column(sa.Column('role_name', sa.String(), nullable=True)) + + op.execute( + sa.text( + """ + UPDATE role_requests rr + SET role_name = r.name + FROM roles r + WHERE rr.role_id = r.id + """ + ) + ) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('role_requests_role_id_fkey'), type_='foreignkey') + batch_op.create_foreign_key( + batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name'] + ) + batch_op.alter_column('role_name', existing_type=sa.String(), nullable=False) + batch_op.drop_column('role_id') + + # user_roles: migrate FK and PK to use role_name (str) + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.add_column(sa.Column('role_name', sa.String(), nullable=True)) + + op.execute( + sa.text( + """ + UPDATE user_roles ur + SET role_name = r.name + FROM roles r + WHERE ur.role_id = r.id + """ + ) + ) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('user_roles_pkey'), type_='primary') + batch_op.drop_constraint(batch_op.f('user_roles_role_id_fkey'), type_='foreignkey') + batch_op.alter_column('role_name', existing_type=sa.String(), nullable=False) + batch_op.drop_column('role_id') + batch_op.create_foreign_key( + batch_op.f('user_roles_role_name_fkey'), 'roles', ['role_name'], ['name'], ondelete='CASCADE' + ) + batch_op.create_primary_key(batch_op.f('user_roles_pkey'), ['user_id', 'role_name']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # user_roles: revert FK/PK to role_id (int) + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.add_column(sa.Column('role_id', sa.INTEGER(), autoincrement=False, nullable=True)) + + op.execute( + sa.text( + """ + UPDATE user_roles ur + SET role_id = r.id + FROM roles r + WHERE ur.role_name = r.name + """ + ) + ) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('user_roles_pkey'), type_='primary') + batch_op.drop_constraint(batch_op.f('user_roles_role_name_fkey'), type_='foreignkey') + batch_op.alter_column('role_id', existing_type=sa.INTEGER(), nullable=False) + batch_op.drop_column('role_name') + batch_op.create_foreign_key( + batch_op.f('user_roles_role_id_fkey'), 'roles', ['role_id'], ['id'], ondelete='CASCADE' + ) + batch_op.create_primary_key(batch_op.f('user_roles_pkey'), ['user_id', 'role_id']) + + # role_requests: revert FK to role_id (int) + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.add_column(sa.Column('role_id', sa.INTEGER(), autoincrement=False, nullable=True)) + + op.execute( + sa.text( + """ + UPDATE role_requests rr + SET role_id = r.id + FROM roles r + WHERE rr.role_name = r.name + """ + ) + ) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') + batch_op.alter_column('role_id', existing_type=sa.INTEGER(), nullable=False) + batch_op.drop_column('role_name') + batch_op.create_foreign_key(batch_op.f('role_requests_role_id_fkey'), 'roles', ['role_id'], ['id']) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/e24fb9138dcd_.py b/backend/alembic/versions/e24fb9138dcd_.py new file mode 100644 index 0000000..275efb7 --- /dev/null +++ b/backend/alembic/versions/e24fb9138dcd_.py @@ -0,0 +1,73 @@ +"""empty message + +Revision ID: e24fb9138dcd +Revises: e15113d4ca9c +Create Date: 2026-04-04 12:58:05.603122 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e24fb9138dcd' +down_revision: Union[str, Sequence[str], None] = 'e15113d4ca9c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Drop dependent FKs before touching the referenced constraint + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('user_roles_role_name_fkey'), type_='foreignkey') + + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('roles_pkey'), type_='primary') + batch_op.drop_constraint(batch_op.f('roles_name_key'), type_='unique') + batch_op.add_column(sa.Column('description', sa.String(length=4096), nullable=False)) + batch_op.add_column(sa.Column('display_name', sa.String(), nullable=False)) + batch_op.drop_column('id') + batch_op.create_primary_key(batch_op.f('roles_pkey'), ['name']) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_foreign_key( + batch_op.f('user_roles_role_name_fkey'), 'roles', ['role_name'], ['name'], ondelete='CASCADE' + ) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.create_foreign_key( + batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name'] + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('user_roles_role_name_fkey'), type_='foreignkey') + + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('roles_pkey'), type_='primary') + batch_op.add_column(sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False)) + batch_op.create_primary_key(batch_op.f('roles_pkey'), ['id']) + batch_op.create_unique_constraint(batch_op.f('roles_name_key'), ['name'], postgresql_nulls_not_distinct=False) + batch_op.drop_column('display_name') + batch_op.drop_column('description') + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_foreign_key( + batch_op.f('user_roles_role_name_fkey'), 'roles', ['role_name'], ['name'], ondelete='CASCADE' + ) + + with op.batch_alter_table('role_requests', schema=None) as batch_op: + batch_op.create_foreign_key( + batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name'] + ) diff --git a/backend/app/models/role.py b/backend/app/models/role.py index 7781eb2..cbfc10f 100644 --- a/backend/app/models/role.py +++ b/backend/app/models/role.py @@ -1,14 +1,13 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship - +from sqlalchemy import String from .base import Base -from .mixin import PKMixin +from .mixin import OptionMixin from .user import User, user_roles -class Role(Base, PKMixin): +class Role(Base, OptionMixin): __tablename__ = "roles" - - name: Mapped[str] = mapped_column(nullable=False, unique=True) + description: Mapped[str] = mapped_column(String(4096)) users: Mapped[list["User"]] = relationship( secondary=user_roles, back_populates="roles", lazy="selectin" diff --git a/backend/app/models/role_request.py b/backend/app/models/role_request.py index e2a3db6..26f0b12 100644 --- a/backend/app/models/role_request.py +++ b/backend/app/models/role_request.py @@ -7,7 +7,7 @@ class RoleRequest(Base, PKMixin): __tablename__ = "role_requests" - role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) + role_name: Mapped[str] = mapped_column(ForeignKey("roles.name")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) role: Mapped["Role"] = relationship(back_populates="requests", lazy="selectin") @@ -19,7 +19,7 @@ class RoleRequest(Base, PKMixin): ) def __repr__(self): - return f"" + return f"" class RoleRequestInfoOption(Base, OptionMixin): __tablename__ = 'role_request_info_options' diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 7fde780..64b850c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,7 +9,7 @@ "user_roles", Base.metadata, Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), - Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), + Column("role_name", ForeignKey("roles.name", ondelete="CASCADE"), primary_key=True), ) diff --git a/backend/app/routes/role_requests.py b/backend/app/routes/role_requests.py index f13e9a0..aff7a18 100644 --- a/backend/app/routes/role_requests.py +++ b/backend/app/routes/role_requests.py @@ -56,7 +56,7 @@ async def create_request( request_create: RoleRequestCreate ): request = RoleRequestCreate.model_validate(request_create) - statement = select(RoleRequest).where(RoleRequest.role_id==request.role_id, + statement = select(RoleRequest).where(RoleRequest.role_name==request.role_name, RoleRequest.user_id==request.user_id) r = (await session.execute(statement)).scalar() if r: diff --git a/backend/app/schemas/option.py b/backend/app/schemas/option.py index f50303c..56e3aef 100644 --- a/backend/app/schemas/option.py +++ b/backend/app/schemas/option.py @@ -5,4 +5,8 @@ class OptionBase(BaseModel): name: str = Field(..., description='Option name') display_name: str = Field(..., description='Option description') + +class OptionUpdate(OptionBase): + name: str | None = None + display_name: str | None = None \ No newline at end of file diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py index 478322b..96d7320 100644 --- a/backend/app/schemas/role.py +++ b/backend/app/schemas/role.py @@ -1,13 +1,14 @@ -from pydantic import BaseModel, Field, field_validator, ConfigDict +from pydantic import Field, field_validator, ConfigDict +from .option import OptionBase, OptionUpdate -class RoleBase(BaseModel): +class RoleBase(OptionBase): model_config = ConfigDict(from_attributes=True) - name: str = Field(..., description="Role name") + description: str = Field(..., description="Role description") -class RoleUpdate(RoleBase): - name: str | None = None +class RoleUpdate(OptionUpdate): + description: str | None = None class RolePublic(RoleBase): pass diff --git a/backend/app/schemas/role_request.py b/backend/app/schemas/role_request.py index 7a089c3..ac645be 100644 --- a/backend/app/schemas/role_request.py +++ b/backend/app/schemas/role_request.py @@ -6,7 +6,7 @@ class RoleRequestBase(BaseModel): model_config = ConfigDict(from_attributes=True) - role_id: int = Field(..., description="Role being requested") + role_name: str = Field(..., description="Role being requested") user_id: int = Field(..., description="User requesting the role") class RoleRequestInfoOptionBase(OptionBase): diff --git a/backend/tests/factories.py b/backend/tests/factories.py index a597726..62686a1 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -43,6 +43,8 @@ class Meta: model = Role name = factory.Iterator(["admin", "user", "jury"]) + display_name = factory.Iterator(["Admin", "User", "Jury"]) + description = factory.Iterator('Test role') class TournamentStatusOptionFactory(BaseFactory): diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index f5e1f9e..f9683c5 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -17,11 +17,11 @@ async def test_role_request_and_approval(create, client, db_session): app.dependency_overrides[get_admin_user] = lambda: user assert len(user.roles) == 0 resp = await client.post('/role-requests/', json={ - 'role_id': role.id, + 'role_name': role.name, 'user_id': user.id, }) assert resp.status_code == 200 - assert resp.json()['role_id'] == role.id + assert resp.json()['role_name'] == role.name assert resp.json()['user_id'] == user.id req_id = resp.json()['id'] s = select(RoleRequest) @@ -40,11 +40,11 @@ async def test_create_role_request_exists(create, client, db_session): user = (await db_session.execute(stmt)).unique().scalar_one() role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user - r = RoleRequest(role_id=role.id, user_id=user.id) + r = RoleRequest(role_name=role.name, user_id=user.id) db_session.add(r) await db_session.commit() resp = await client.post('/role-requests/', json={ - 'role_id': role.id, + 'role_name': role.name, 'user_id': user.id, }) assert resp.status_code == 400 @@ -61,7 +61,7 @@ async def test_role_request_disapproval(create, client, db_session): user = (await db_session.execute(stmt)).unique().scalar_one() role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user - r = RoleRequest(role_id=role.id, user_id=user.id) + r = RoleRequest(role_name=role.name, user_id=user.id) db_session.add(r) await db_session.commit() await db_session.refresh(r) @@ -79,13 +79,13 @@ async def test_get_role_request(create, client, db_session): user = (await db_session.execute(stmt)).unique().scalar_one() role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user - r = RoleRequest(role_id=role.id, user_id=user.id) + r = RoleRequest(role_name=role.name, user_id=user.id) db_session.add(r) await db_session.commit() await db_session.refresh(r) resp = await client.get(f'/role-requests/{r.id}/') assert resp.status_code == 200 - assert resp.json()['role_id'] == r.role_id + assert resp.json()['role_name'] == r.role_name assert resp.json()['user_id'] == r.user_id app.dependency_overrides.pop(get_current_user) @@ -96,7 +96,7 @@ async def test_delete_role_request(create, client, db_session): user = (await db_session.execute(stmt)).unique().scalar_one() role = await create(RoleFactory) app.dependency_overrides[get_current_user] = lambda: user - r = RoleRequest(role_id=role.id, user_id=user.id) + r = RoleRequest(role_name=role.name, user_id=user.id) db_session.add(r) await db_session.commit() await db_session.refresh(r) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 5476c89..141e9c0 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -66,7 +66,7 @@ async def test_create_user_duplicate_email(db_session, create): # ROLE TESTS async def test_create_role(create): role = await create(RoleFactory, name="jury") - assert role.id is not None + assert role.name is not None assert role.name == "jury" @@ -632,7 +632,7 @@ async def test_role_requests_relationship(db_session, create): await create(RoleRequestFactory, role=role) await create(RoleRequestFactory, role=role) - stmt = select(Role).where(Role.id == role.id).options(selectinload(Role.requests)) + stmt = select(Role).where(role.name == role.name).options(selectinload(Role.requests)) result = await db_session.execute(stmt) db_role = result.scalar_one() From f46728ff4dcbef8a1bd1f87a1ab7d87193c7595f Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 4 Apr 2026 13:29:09 +0300 Subject: [PATCH 109/369] feat: implement GET endpoints for roles --- backend/app/main.py | 2 ++ backend/app/routes/roles.py | 24 ++++++++++++++++++++++++ backend/app/schemas/__init__.py | 1 + 3 files changed, 27 insertions(+) create mode 100644 backend/app/routes/roles.py diff --git a/backend/app/main.py b/backend/app/main.py index 6996152..87efcfc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ import app.routes.users as users import app.routes.profile as profile import app.routes.role_requests as role_requests +import app.routes.roles as roles from fastapi.middleware.cors import CORSMiddleware app = FastAPI() @@ -24,3 +25,4 @@ app.include_router(users.router) app.include_router(profile.router) app.include_router(role_requests.router) +app.include_router(roles.router) diff --git a/backend/app/routes/roles.py b/backend/app/routes/roles.py new file mode 100644 index 0000000..f05c53d --- /dev/null +++ b/backend/app/routes/roles.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas import RolePublic +from app.models import Role +from app.dependencies import SessionDep +from sqlalchemy import select + +router = APIRouter(prefix='/roles', tags=['roles']) + +async def get_role(name: str, session: SessionDep): + statement = select(Role).where(Role.name==name) + role = (await session.execute(statement)).scalar() + if not role: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Role not found!') + return role + +@router.get('/', response_model=list[RolePublic]) +async def roles(session: SessionDep): + statement = select(Role) + roles = await session.execute(statement) + return roles.scalars().all() + +@router.get('/{name}/', response_model=RolePublic) +async def role(name: str, session: SessionDep): + return await get_role(name, session) \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d6b03e8..14a87ee 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -5,3 +5,4 @@ from .notification import NotificationPublic, NotificationCreate from .user import UserModel, UserPublic, UserUpdate, UserCreate, CurrentUser from .role_request import RoleRequestPublic, RoleRequestCreate +from .role import RoleCreate, RolePublic, RoleUpdate \ No newline at end of file From fa1b6adc4d67126ccdc9e2430c72010928c466a5 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sat, 4 Apr 2026 17:39:37 +0300 Subject: [PATCH 110/369] feat(role-request): Add backend requests --- backend/alembic/versions/0b99af1c589a_.py | 39 ------ backend/alembic/versions/1afa2bfb3587_.py | 86 ------------ backend/alembic/versions/3944cb4c1dfe_.py | 36 ----- backend/alembic/versions/6a4dcbf12865_.py | 36 ----- backend/alembic/versions/6bb77b2a4118_.py | 65 --------- backend/alembic/versions/79c35894ec8e_.py | 39 ------ ...955b1_.py => 8bb9c2b1a990_my_local_fix.py} | 109 +++++++++++++-- backend/alembic/versions/b09774274996_.py | 65 --------- backend/alembic/versions/b6b9a8bb78e1_.py | 68 ---------- backend/alembic/versions/bf6e23f5cf9a_.py | 64 --------- backend/alembic/versions/c93036e26c52_.py | 42 ------ backend/alembic/versions/e15113d4ca9c_.py | 124 ------------------ backend/alembic/versions/e24fb9138dcd_.py | 73 ----------- backend/alembic/versions/f734dfe1b30d_.py | 32 ----- backend/alembic/versions/fad843168dc7_.py | 92 ------------- frontend/package-lock.json | 53 ++++---- frontend/package.json | 1 + frontend/src/App.tsx | 4 + frontend/src/api/requests/roleRequest.ts | 32 +++++ .../pages/GetOganizerRole/GetOganizerRole.tsx | 61 ++++++++- 20 files changed, 215 insertions(+), 906 deletions(-) delete mode 100644 backend/alembic/versions/0b99af1c589a_.py delete mode 100644 backend/alembic/versions/1afa2bfb3587_.py delete mode 100644 backend/alembic/versions/3944cb4c1dfe_.py delete mode 100644 backend/alembic/versions/6a4dcbf12865_.py delete mode 100644 backend/alembic/versions/6bb77b2a4118_.py delete mode 100644 backend/alembic/versions/79c35894ec8e_.py rename backend/alembic/versions/{9c3d5ce955b1_.py => 8bb9c2b1a990_my_local_fix.py} (57%) delete mode 100644 backend/alembic/versions/b09774274996_.py delete mode 100644 backend/alembic/versions/b6b9a8bb78e1_.py delete mode 100644 backend/alembic/versions/bf6e23f5cf9a_.py delete mode 100644 backend/alembic/versions/c93036e26c52_.py delete mode 100644 backend/alembic/versions/e15113d4ca9c_.py delete mode 100644 backend/alembic/versions/e24fb9138dcd_.py delete mode 100644 backend/alembic/versions/f734dfe1b30d_.py delete mode 100644 backend/alembic/versions/fad843168dc7_.py create mode 100644 frontend/src/api/requests/roleRequest.ts diff --git a/backend/alembic/versions/0b99af1c589a_.py b/backend/alembic/versions/0b99af1c589a_.py deleted file mode 100644 index d07526c..0000000 --- a/backend/alembic/versions/0b99af1c589a_.py +++ /dev/null @@ -1,39 +0,0 @@ -"""empty message - -Revision ID: 0b99af1c589a -Revises: c93036e26c52 -Create Date: 2026-03-08 22:03:18.929262 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '0b99af1c589a' -down_revision: Union[str, Sequence[str], None] = 'c93036e26c52' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('notifications', - sa.Column('body', sa.String(length=4096), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('body') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('notifications') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/1afa2bfb3587_.py b/backend/alembic/versions/1afa2bfb3587_.py deleted file mode 100644 index 58d615a..0000000 --- a/backend/alembic/versions/1afa2bfb3587_.py +++ /dev/null @@ -1,86 +0,0 @@ -"""empty message - -Revision ID: 1afa2bfb3587 -Revises: b6b9a8bb78e1 -Create Date: 2026-03-02 19:58:36.054914 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '1afa2bfb3587' -down_revision: Union[str, Sequence[str], None] = 'b6b9a8bb78e1' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['main_id'], ['name']) - batch_op.drop_column('category_id') - - with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) - - with op.batch_alter_table('teams', schema=None) as batch_op: - batch_op.alter_column('captain_id', - existing_type=sa.INTEGER(), - nullable=True) - batch_op.create_unique_constraint(None, ['team_email']) - batch_op.create_unique_constraint(None, ['contact_info']) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'tournaments', ['tournament_id'], ['id']) - batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.alter_column('active_task_id', - existing_type=sa.INTEGER(), - nullable=True) - - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - batch_op.drop_column('cleated_at') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - batch_op.drop_column('created_at') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.alter_column('active_task_id', - existing_type=sa.INTEGER(), - nullable=False) - - with op.batch_alter_table('teams', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'team_members', ['captain_id'], ['id']) - batch_op.drop_constraint(None, type_='unique') - batch_op.drop_constraint(None, type_='unique') - batch_op.alter_column('captain_id', - existing_type=sa.INTEGER(), - nullable=False) - - with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_statuses', ['category_id'], ['name']) - - with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: - batch_op.add_column(sa.Column('category_id', sa.VARCHAR(), nullable=False)) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name']) - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/3944cb4c1dfe_.py b/backend/alembic/versions/3944cb4c1dfe_.py deleted file mode 100644 index 6b734e0..0000000 --- a/backend/alembic/versions/3944cb4c1dfe_.py +++ /dev/null @@ -1,36 +0,0 @@ -"""empty message - -Revision ID: 3944cb4c1dfe -Revises: 79c35894ec8e -Create Date: 2026-03-13 15:20:40.620748 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '3944cb4c1dfe' -down_revision: Union[str, Sequence[str], None] = '79c35894ec8e' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('password') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('password', sa.VARCHAR(), autoincrement=False, nullable=False)) - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/6a4dcbf12865_.py b/backend/alembic/versions/6a4dcbf12865_.py deleted file mode 100644 index a0e8641..0000000 --- a/backend/alembic/versions/6a4dcbf12865_.py +++ /dev/null @@ -1,36 +0,0 @@ -"""empty message - -Revision ID: 6a4dcbf12865 -Revises: 3944cb4c1dfe -Create Date: 2026-03-14 08:57:34.972551 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '6a4dcbf12865' -down_revision: Union[str, Sequence[str], None] = '3944cb4c1dfe' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('firebase_uid', sa.String(), nullable=False)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('firebase_uid') - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/6bb77b2a4118_.py b/backend/alembic/versions/6bb77b2a4118_.py deleted file mode 100644 index 117d330..0000000 --- a/backend/alembic/versions/6bb77b2a4118_.py +++ /dev/null @@ -1,65 +0,0 @@ -"""empty message - -Revision ID: 6bb77b2a4118 -Revises: bf6e23f5cf9a -Create Date: 2026-03-14 09:02:18.122578 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '6bb77b2a4118' -down_revision: Union[str, Sequence[str], None] = 'bf6e23f5cf9a' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('firebase_uid', sa.String(), nullable=False), - sa.Column('full_name', sa.String(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email'), - sa.UniqueConstraint('firebase_uid') - ) - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id']) - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - op.drop_table('users') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/79c35894ec8e_.py b/backend/alembic/versions/79c35894ec8e_.py deleted file mode 100644 index 8b03e3f..0000000 --- a/backend/alembic/versions/79c35894ec8e_.py +++ /dev/null @@ -1,39 +0,0 @@ -"""empty message - -Revision ID: 79c35894ec8e -Revises: 0b99af1c589a -Create Date: 2026-03-08 22:23:54.110885 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '79c35894ec8e' -down_revision: Union[str, Sequence[str], None] = '0b99af1c589a' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('role_requests', - sa.Column('role_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('role_requests') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/9c3d5ce955b1_.py b/backend/alembic/versions/8bb9c2b1a990_my_local_fix.py similarity index 57% rename from backend/alembic/versions/9c3d5ce955b1_.py rename to backend/alembic/versions/8bb9c2b1a990_my_local_fix.py index 1cb0162..b8d2bf8 100644 --- a/backend/alembic/versions/9c3d5ce955b1_.py +++ b/backend/alembic/versions/8bb9c2b1a990_my_local_fix.py @@ -1,8 +1,8 @@ -"""empty message +"""my_local_fix -Revision ID: 9c3d5ce955b1 -Revises: 1afa2bfb3587 -Create Date: 2026-03-02 20:04:34.646529 +Revision ID: 8bb9c2b1a990 +Revises: +Create Date: 2026-04-04 16:45:51.775994 """ from typing import Sequence, Union @@ -12,8 +12,8 @@ # revision identifiers, used by Alembic. -revision: str = '9c3d5ce955b1' -down_revision: Union[str, Sequence[str], None] = '1afa2bfb3587' +revision: str = '8bb9c2b1a990' +down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,11 +21,21 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### + op.create_table('role_request_info_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) op.create_table('roles', + sa.Column('description', sa.String(length=4096), nullable=False), sa.Column('name', sa.String(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('submission_url_options', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') ) op.create_table('task_requirement_categories', sa.Column('main_id', sa.String(), nullable=True), @@ -58,13 +68,30 @@ def upgrade() -> None: sa.UniqueConstraint('name') ) op.create_table('users', + sa.Column('firebase_uid', sa.String(), nullable=False), sa.Column('full_name', sa.String(), nullable=False), sa.Column('email', sa.String(), nullable=False), - sa.Column('password', sa.String(), nullable=False), sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), sa.Column('id', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') + sa.UniqueConstraint('email'), + sa.UniqueConstraint('firebase_uid') + ) + op.create_table('notifications', + sa.Column('body', sa.String(length=4096), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('body') + ) + op.create_table('role_requests', + sa.Column('role_name', sa.String(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') ) op.create_table('task_requirement_options', sa.Column('category_id', sa.String(), nullable=False), @@ -87,10 +114,19 @@ def upgrade() -> None: ) op.create_table('user_roles', sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('role_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.Column('role_name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('user_id', 'role_id') + sa.PrimaryKeyConstraint('user_id', 'role_name') + ) + op.create_table('role_request_info', + sa.Column('request_id', sa.Integer(), nullable=False), + sa.Column('option_name', sa.String(), nullable=False), + sa.Column('value', sa.String(length=4096), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['option_name'], ['role_request_info_options.name'], ), + sa.ForeignKeyConstraint(['request_id'], ['role_requests.id'], ), + sa.PrimaryKeyConstraint('id') ) op.create_table('task_requirements', sa.Column('requirement_id', sa.String(), nullable=False), @@ -129,22 +165,67 @@ def upgrade() -> None: sa.UniqueConstraint('name'), sa.UniqueConstraint('team_email') ) + op.create_table('submissions', + sa.Column('team_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ), + sa.PrimaryKeyConstraint('team_id') + ) + op.create_table('evaluations', + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('jury_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('submission_id', 'jury_id') + ) + op.create_table('submission_urls', + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('url_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), + sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ), + sa.PrimaryKeyConstraint('submission_id', 'url_id') + ) + op.create_table('requirement_evaluations', + sa.Column('evaluation_id', sa.Integer(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('evaluation_requirements', + sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False), + sa.Column('requirement_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id') + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('evaluation_requirements') + op.drop_table('requirement_evaluations') + op.drop_table('submission_urls') + op.drop_table('evaluations') + op.drop_table('submissions') op.drop_table('teams') op.drop_table('tournaments') op.drop_table('task_requirements') + op.drop_table('role_request_info') op.drop_table('user_roles') op.drop_table('tasks') op.drop_table('task_requirement_options') + op.drop_table('role_requests') + op.drop_table('notifications') op.drop_table('users') op.drop_table('tournament_status_options') op.drop_table('team_members') op.drop_table('task_statuses') op.drop_table('task_requirement_categories') + op.drop_table('submission_url_options') op.drop_table('roles') + op.drop_table('role_request_info_options') # ### end Alembic commands ### diff --git a/backend/alembic/versions/b09774274996_.py b/backend/alembic/versions/b09774274996_.py deleted file mode 100644 index 99d12fc..0000000 --- a/backend/alembic/versions/b09774274996_.py +++ /dev/null @@ -1,65 +0,0 @@ -"""empty message - -Revision ID: b09774274996 -Revises: 6bb77b2a4118 -Create Date: 2026-03-14 09:04:55.414283 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'b09774274996' -down_revision: Union[str, Sequence[str], None] = '6bb77b2a4118' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('firebase_uid', sa.String(), nullable=False), - sa.Column('full_name', sa.String(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email'), - sa.UniqueConstraint('firebase_uid') - ) - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id']) - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - op.drop_table('users') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/b6b9a8bb78e1_.py b/backend/alembic/versions/b6b9a8bb78e1_.py deleted file mode 100644 index f7bc1a3..0000000 --- a/backend/alembic/versions/b6b9a8bb78e1_.py +++ /dev/null @@ -1,68 +0,0 @@ -"""empty message - -Revision ID: b6b9a8bb78e1 -Revises: -Create Date: 2026-03-02 19:57:51.433308 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'b6b9a8bb78e1' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('task_requirement_categories', sa.Column('main_id', sa.String(), nullable=True)) - op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['main_id'], ['name']) - op.drop_column('task_requirement_categories', 'category_id') - op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_options', 'task_requirement_categories', ['category_id'], ['name']) - op.alter_column('teams', 'captain_id', - existing_type=sa.INTEGER(), - nullable=True) - op.create_unique_constraint(None, 'teams', ['team_email']) - op.create_unique_constraint(None, 'teams', ['contact_info']) - op.drop_constraint(None, 'teams', type_='foreignkey') - op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id'], ondelete='CASCADE') - op.create_foreign_key(None, 'teams', 'tournaments', ['tournament_id'], ['id']) - op.alter_column('tournaments', 'active_task_id', - existing_type=sa.INTEGER(), - nullable=True) - op.add_column('users', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.drop_column('users', 'cleated_at') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('cleated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.drop_column('users', 'created_at') - op.alter_column('tournaments', 'active_task_id', - existing_type=sa.INTEGER(), - nullable=False) - op.drop_constraint(None, 'teams', type_='foreignkey') - op.drop_constraint(None, 'teams', type_='foreignkey') - op.create_foreign_key(None, 'teams', 'team_members', ['captain_id'], ['id']) - op.drop_constraint(None, 'teams', type_='unique') - op.drop_constraint(None, 'teams', type_='unique') - op.alter_column('teams', 'captain_id', - existing_type=sa.INTEGER(), - nullable=False) - op.drop_constraint(None, 'task_requirement_options', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_options', 'task_statuses', ['category_id'], ['name']) - op.add_column('task_requirement_categories', sa.Column('category_id', sa.VARCHAR(), nullable=False)) - op.drop_constraint(None, 'task_requirement_categories', type_='foreignkey') - op.create_foreign_key(None, 'task_requirement_categories', 'task_requirement_categories', ['category_id'], ['name']) - op.drop_column('task_requirement_categories', 'main_id') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/bf6e23f5cf9a_.py b/backend/alembic/versions/bf6e23f5cf9a_.py deleted file mode 100644 index ed9eef5..0000000 --- a/backend/alembic/versions/bf6e23f5cf9a_.py +++ /dev/null @@ -1,64 +0,0 @@ -"""empty message - -Revision ID: bf6e23f5cf9a -Revises: 6a4dcbf12865 -Create Date: 2026-03-14 09:00:33.301671 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'bf6e23f5cf9a' -down_revision: Union[str, Sequence[str], None] = '6a4dcbf12865' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('firebase_uid', sa.String(), nullable=False), - sa.Column('full_name', sa.String(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('firebase_uid', 'id'), - sa.UniqueConstraint('email') - ) - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id']) - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id']) - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - - op.drop_table('users') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/c93036e26c52_.py b/backend/alembic/versions/c93036e26c52_.py deleted file mode 100644 index f317272..0000000 --- a/backend/alembic/versions/c93036e26c52_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""empty message - -Revision ID: c93036e26c52 -Revises: 9c3d5ce955b1 -Create Date: 2026-03-08 22:01:28.106624 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'c93036e26c52' -down_revision: Union[str, Sequence[str], None] = '9c3d5ce955b1' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('tasks', schema=None) as batch_op: - batch_op.create_foreign_key('fk_task_tournament', 'tournaments', ['tournament_id'], ['id'], use_alter=True) - - with op.batch_alter_table('team_members', schema=None) as batch_op: - batch_op.create_foreign_key('fk_teammember_team', 'teams', ['team_id'], ['id'], use_alter=True) - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('team_members', schema=None) as batch_op: - batch_op.drop_constraint('fk_teammember_team', type_='foreignkey') - - with op.batch_alter_table('tasks', schema=None) as batch_op: - batch_op.drop_constraint('fk_task_tournament', type_='foreignkey') - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/e15113d4ca9c_.py b/backend/alembic/versions/e15113d4ca9c_.py deleted file mode 100644 index 5a2e570..0000000 --- a/backend/alembic/versions/e15113d4ca9c_.py +++ /dev/null @@ -1,124 +0,0 @@ -"""empty message - -Revision ID: e15113d4ca9c -Revises: f734dfe1b30d -Create Date: 2026-04-04 12:47:44.040902 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'e15113d4ca9c' -down_revision: Union[str, Sequence[str], None] = 'f734dfe1b30d' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - # role_requests: migrate FK from role_id (int) to role_name (str) - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.add_column(sa.Column('role_name', sa.String(), nullable=True)) - - op.execute( - sa.text( - """ - UPDATE role_requests rr - SET role_name = r.name - FROM roles r - WHERE rr.role_id = r.id - """ - ) - ) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('role_requests_role_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key( - batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name'] - ) - batch_op.alter_column('role_name', existing_type=sa.String(), nullable=False) - batch_op.drop_column('role_id') - - # user_roles: migrate FK and PK to use role_name (str) - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.add_column(sa.Column('role_name', sa.String(), nullable=True)) - - op.execute( - sa.text( - """ - UPDATE user_roles ur - SET role_name = r.name - FROM roles r - WHERE ur.role_id = r.id - """ - ) - ) - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('user_roles_pkey'), type_='primary') - batch_op.drop_constraint(batch_op.f('user_roles_role_id_fkey'), type_='foreignkey') - batch_op.alter_column('role_name', existing_type=sa.String(), nullable=False) - batch_op.drop_column('role_id') - batch_op.create_foreign_key( - batch_op.f('user_roles_role_name_fkey'), 'roles', ['role_name'], ['name'], ondelete='CASCADE' - ) - batch_op.create_primary_key(batch_op.f('user_roles_pkey'), ['user_id', 'role_name']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - # user_roles: revert FK/PK to role_id (int) - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.add_column(sa.Column('role_id', sa.INTEGER(), autoincrement=False, nullable=True)) - - op.execute( - sa.text( - """ - UPDATE user_roles ur - SET role_id = r.id - FROM roles r - WHERE ur.role_name = r.name - """ - ) - ) - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('user_roles_pkey'), type_='primary') - batch_op.drop_constraint(batch_op.f('user_roles_role_name_fkey'), type_='foreignkey') - batch_op.alter_column('role_id', existing_type=sa.INTEGER(), nullable=False) - batch_op.drop_column('role_name') - batch_op.create_foreign_key( - batch_op.f('user_roles_role_id_fkey'), 'roles', ['role_id'], ['id'], ondelete='CASCADE' - ) - batch_op.create_primary_key(batch_op.f('user_roles_pkey'), ['user_id', 'role_id']) - - # role_requests: revert FK to role_id (int) - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.add_column(sa.Column('role_id', sa.INTEGER(), autoincrement=False, nullable=True)) - - op.execute( - sa.text( - """ - UPDATE role_requests rr - SET role_id = r.id - FROM roles r - WHERE rr.role_name = r.name - """ - ) - ) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') - batch_op.alter_column('role_id', existing_type=sa.INTEGER(), nullable=False) - batch_op.drop_column('role_name') - batch_op.create_foreign_key(batch_op.f('role_requests_role_id_fkey'), 'roles', ['role_id'], ['id']) - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/e24fb9138dcd_.py b/backend/alembic/versions/e24fb9138dcd_.py deleted file mode 100644 index 275efb7..0000000 --- a/backend/alembic/versions/e24fb9138dcd_.py +++ /dev/null @@ -1,73 +0,0 @@ -"""empty message - -Revision ID: e24fb9138dcd -Revises: e15113d4ca9c -Create Date: 2026-04-04 12:58:05.603122 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'e24fb9138dcd' -down_revision: Union[str, Sequence[str], None] = 'e15113d4ca9c' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # Drop dependent FKs before touching the referenced constraint - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('user_roles_role_name_fkey'), type_='foreignkey') - - with op.batch_alter_table('roles', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('roles_pkey'), type_='primary') - batch_op.drop_constraint(batch_op.f('roles_name_key'), type_='unique') - batch_op.add_column(sa.Column('description', sa.String(length=4096), nullable=False)) - batch_op.add_column(sa.Column('display_name', sa.String(), nullable=False)) - batch_op.drop_column('id') - batch_op.create_primary_key(batch_op.f('roles_pkey'), ['name']) - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.create_foreign_key( - batch_op.f('user_roles_role_name_fkey'), 'roles', ['role_name'], ['name'], ondelete='CASCADE' - ) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.create_foreign_key( - batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name'] - ) - - -def downgrade() -> None: - """Downgrade schema.""" - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('user_roles_role_name_fkey'), type_='foreignkey') - - with op.batch_alter_table('roles', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('roles_pkey'), type_='primary') - batch_op.add_column(sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False)) - batch_op.create_primary_key(batch_op.f('roles_pkey'), ['id']) - batch_op.create_unique_constraint(batch_op.f('roles_name_key'), ['name'], postgresql_nulls_not_distinct=False) - batch_op.drop_column('display_name') - batch_op.drop_column('description') - - with op.batch_alter_table('user_roles', schema=None) as batch_op: - batch_op.create_foreign_key( - batch_op.f('user_roles_role_name_fkey'), 'roles', ['role_name'], ['name'], ondelete='CASCADE' - ) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.create_foreign_key( - batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name'] - ) diff --git a/backend/alembic/versions/f734dfe1b30d_.py b/backend/alembic/versions/f734dfe1b30d_.py deleted file mode 100644 index 258d0ff..0000000 --- a/backend/alembic/versions/f734dfe1b30d_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: f734dfe1b30d -Revises: fad843168dc7 -Create Date: 2026-04-02 21:41:39.029760 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'f734dfe1b30d' -down_revision: Union[str, Sequence[str], None] = 'fad843168dc7' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/backend/alembic/versions/fad843168dc7_.py b/backend/alembic/versions/fad843168dc7_.py deleted file mode 100644 index 24dc438..0000000 --- a/backend/alembic/versions/fad843168dc7_.py +++ /dev/null @@ -1,92 +0,0 @@ -"""empty message - -Revision ID: fad843168dc7 -Revises: b09774274996 -Create Date: 2026-04-02 21:27:33.721263 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'fad843168dc7' -down_revision: Union[str, Sequence[str], None] = 'b09774274996' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('role_request_info_options', - sa.Column('name', sa.String(), nullable=False), - sa.Column('display_name', sa.String(), nullable=False), - sa.PrimaryKeyConstraint('name') - ) - op.create_table('submission_url_options', - sa.Column('name', sa.String(), nullable=False), - sa.Column('display_name', sa.String(), nullable=False), - sa.PrimaryKeyConstraint('name') - ) - op.create_table('role_request_info', - sa.Column('request_id', sa.Integer(), nullable=False), - sa.Column('option_name', sa.String(), nullable=False), - sa.Column('value', sa.String(length=4096), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['option_name'], ['role_request_info_options.name'], ), - sa.ForeignKeyConstraint(['request_id'], ['role_requests.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('submissions', - sa.Column('team_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ), - sa.PrimaryKeyConstraint('team_id') - ) - op.create_table('evaluations', - sa.Column('submission_id', sa.Integer(), nullable=False), - sa.Column('jury_id', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('submission_id', 'jury_id') - ) - op.create_table('submission_urls', - sa.Column('submission_id', sa.Integer(), nullable=False), - sa.Column('url_id', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ), - sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ), - sa.PrimaryKeyConstraint('submission_id', 'url_id') - ) - op.create_table('requirement_evaluations', - sa.Column('evaluation_id', sa.Integer(), nullable=False), - sa.Column('score', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('evaluation_requirements', - sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False), - sa.Column('requirement_id', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('evaluation_requirements') - op.drop_table('requirement_evaluations') - op.drop_table('submission_urls') - op.drop_table('evaluations') - op.drop_table('submissions') - op.drop_table('role_request_info') - op.drop_table('submission_url_options') - op.drop_table('role_request_info_options') - # ### end Alembic commands ### diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2488475..777564d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "lottie-react": "^2.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.1" @@ -70,7 +71,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1026,7 +1026,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", @@ -1093,7 +1092,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.9", "@firebase/component": "0.7.1", @@ -1109,8 +1107,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.12.1", @@ -1561,7 +1558,6 @@ "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2556,7 +2552,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -2686,7 +2681,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2752,7 +2746,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3017,7 +3010,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3148,7 +3140,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3323,7 +3314,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/cva": { @@ -3555,7 +3545,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3819,7 +3808,6 @@ "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz", "integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/ai": "2.9.0", "@firebase/analytics": "0.10.20", @@ -4013,6 +4001,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4702,7 +4699,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -4815,7 +4811,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4912,7 +4907,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4922,7 +4916,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4930,12 +4923,28 @@ "react": "^19.2.4" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5006,8 +5015,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5296,7 +5304,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5390,7 +5397,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5594,7 +5600,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 495b526..82e78cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "lottie-react": "^2.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.1" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ca9866..3df78a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,9 +12,12 @@ import { Footer } from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { GetOrganizerRole } from "./pages/GetOganizerRole/GetOganizerRole"; +import {Toaster} from 'react-hot-toast' export const App = () => { return ( + <> +
    @@ -39,5 +42,6 @@ export const App = () => {
    + ); }; diff --git a/frontend/src/api/requests/roleRequest.ts b/frontend/src/api/requests/roleRequest.ts new file mode 100644 index 0000000..83bc0e1 --- /dev/null +++ b/frontend/src/api/requests/roleRequest.ts @@ -0,0 +1,32 @@ +import apiClient from "../client"; +import { auth } from "../../firebase"; + +interface Info { + option_name: string; + value: string; +} + +export const roleRequest = async (role_name: string, user_id: number, info: Info[]) => { + const user = auth.currentUser; + + if (!user) { + throw new Error("Користувач не авторизований"); + } + + const token = await user.getIdToken(); + const response = await apiClient.post( + "/role-requests/", + { + role_name: role_name, + user_id: user_id, + info: info + }, + { + headers: { + Authorization: `Bearer ${token}`, + } + } + ); + + return response.status; +} \ No newline at end of file diff --git a/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx b/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx index 70b2120..cb122b2 100644 --- a/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx +++ b/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx @@ -1,8 +1,55 @@ import './GetOrganizerRole.css'; import Lottie from 'lottie-react'; import star from './star.json'; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; +import { roleRequest } from '@/api/requests/roleRequest'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; const GetOrganizerRole = () => { + const currentUser = useSelector((state: RootState) => state.user); + const navigate = useNavigate(); + + const createRequests = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentUser?.id) { + toast.error("Користувач не знайдений або не авторизований!"); + return; + } + + const form = e.currentTarget; + const formData = new FormData(form); + + const info = [ + { option_name: "ПІБ", value: formData.get("fullName") as string }, + { option_name: "Контакти", value: formData.get("contact") as string }, + { option_name: "Вік", value: formData.get("age") as string }, + { option_name: "Досвід", value: formData.get("experience") as string }, + { option_name: "Причина", value: formData.get("reason") as string }, + { option_name: "Плани", value: formData.get("plans") as string }, + ]; + + try { + const status = await roleRequest("user", currentUser.id, info); + console.log("Успішний статус:", status); + + toast.success("Заявку відправлено! Очікуйте на відповідь"); + form.reset(); + navigate("/"); + + } catch (error: any) { + console.error("Помилка відправки заявки:", error); + if (error.response && error.response.status === 400) { + if (error.response.data?.detail === "Role requests already exists!") { + toast.error("Ви вже подавали заявку на цю роль! Очікуйте на рішення."); + return; + } + } + toast.error("Щось пішло не так. Спробуйте пізніше."); + } + } + return(
    @@ -28,34 +75,34 @@ const GetOrganizerRole = () => {

    * — обов'язкове поле

    -
    e.preventDefault()}> +
    - +
    - +
    - +
    - +
    - +
    - +
    From 8fec987660eb1babc921b308746a7eea109f369e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sun, 5 Apr 2026 10:05:02 +0300 Subject: [PATCH 111/369] feat(tests): Setup all for testing and write first tests for TournamenPage --- frontend/package-lock.json | 1194 ++++++++++++++++- frontend/package.json | 12 +- .../TournamentPage/TournamentPage.test.tsx | 36 + .../TournamentPage.test.tsx.snap | 167 +++ frontend/src/setupTests.ts | 1 + frontend/vite.config.ts | 11 +- 6 files changed, 1384 insertions(+), 37 deletions(-) create mode 100644 frontend/src/pages/TournamentPage/TournamentPage.test.tsx create mode 100644 frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap create mode 100644 frontend/src/setupTests.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 777564d..b757c6c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,9 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tanstack/eslint-plugin-query": "^5.95.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -35,11 +38,81 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.0.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.2" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.5.tgz", + "integrity": "sha512-8cMAA1bE66Mb/tfmkhcfJLjEPgyT7SSy6lW6id5XL113ai1ky76d/1L27sGnXCMsLfq66DInAU3OzuahB4lu9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.6.tgz", + "integrity": "sha512-Tgmk6EQM0nc9xvp7sEHRVavbknhb/vGKht+04yAT3t5KQwZ02CSobCtcFgaHH04ZrjD1BhEKNA8tRhzFV20gkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -274,6 +347,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -322,6 +405,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -895,6 +1131,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@firebase-oss/ui-core": { "version": "7.0.2-beta", "resolved": "https://registry.npmjs.org/@firebase-oss/ui-core/-/ui-core-7.0.2-beta.tgz", @@ -2608,6 +2862,104 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2653,6 +3005,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3004,59 +3374,172 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3075,6 +3558,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3109,6 +3612,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3198,6 +3711,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3310,6 +3833,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3336,6 +3880,20 @@ } } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3354,6 +3912,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-formdata": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz", @@ -3376,6 +3941,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3391,6 +3966,14 @@ "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3431,6 +4014,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3449,6 +4045,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3713,6 +4316,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3723,6 +4336,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4094,6 +4717,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", @@ -4153,6 +4789,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4185,6 +4831,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4221,6 +4874,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4611,6 +5315,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4629,6 +5344,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4650,6 +5372,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4717,6 +5449,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4780,6 +5523,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4800,6 +5556,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4856,6 +5619,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -4940,6 +5733,14 @@ "react-dom": ">=16" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -5011,6 +5812,20 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5035,6 +5850,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -5115,6 +5940,19 @@ ], "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5160,6 +5998,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5169,6 +6014,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5195,6 +6054,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5221,6 +6093,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -5250,6 +6129,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5266,6 +6162,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5336,6 +6288,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5466,12 +6428,117 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -5495,6 +6562,31 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5511,6 +6603,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5538,6 +6647,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 82e78cf..512402b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@firebase-oss/ui-react": "^7.0.2-beta", @@ -29,6 +32,9 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tanstack/eslint-plugin-query": "^5.95.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -37,8 +43,10 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.0.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.2" } } diff --git a/frontend/src/pages/TournamentPage/TournamentPage.test.tsx b/frontend/src/pages/TournamentPage/TournamentPage.test.tsx new file mode 100644 index 0000000..ebfeef5 --- /dev/null +++ b/frontend/src/pages/TournamentPage/TournamentPage.test.tsx @@ -0,0 +1,36 @@ +import { vi, describe, it, expect } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TournamentPage } from './TournamentPage'; + +vi.mock('../../components/Header', () => ({ + Header: () =>
    Фейковий Хедер
    +})); + +vi.mock('../../components/Hero', () => ({ + Hero: () =>
    Фейковий Хіро з анімацією
    +})); + +const placeholderTabs = ['Шукають команду', 'Команди (3)', 'Результати']; + +describe('TournamentPage', () => { + it('matches snapshot', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('shows DescriptionTab by default', () => { + render(); + expect(screen.getByText('Що потрібно зробити?')).toBeInTheDocument(); + }); + + it.each(placeholderTabs)('shows PlaceholderTab on "%s" click', async (tabName) => { + render(); + + await userEvent.click(screen.getByText(tabName)); + + expect(screen.getByText('Скоро буде...')).toBeInTheDocument(); + expect(screen.queryByText('Що потрібно зробити?')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap b/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap new file mode 100644 index 0000000..2c5b10e --- /dev/null +++ b/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap @@ -0,0 +1,167 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TournamentPage > matches snapshot 1`] = ` +
    +
    +
    + Фейковий Хедер +
    +
    + Фейковий Хіро з анімацією +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +

    + Що потрібно зробити? +

    +

    + Ваша мета — створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс +

    +
    +
    +

    + + + + + Ключові вимоги: +

    +
      +
    • +
      +

      + створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, +

      +
    • +
    • +
      +

      + створити ядро аналог лінукс, +

      +
    • +
    • +
      +

      + створити ядро аналог лінукс, створити ядро аналог лінукс, +

      +
    • +
    +
    +
    +

    + Стек технологій: +

    +

    + Жодних жорстких обмежень! Всього лиш вимога писати на перфокатрі, та використовуючи два резистора і пачку мівіни змусити це чудо запуститись. +

    +
    + + Перфокарти + + + Два резистора + + + Пачка мівіни + +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts new file mode 100644 index 0000000..6df58f0 --- /dev/null +++ b/frontend/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9887a93..1c510b1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -12,4 +13,12 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, -}) + test: { + globals: true, + environment: 'jsdom', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) \ No newline at end of file From d2c18eb547f7f436c68a29285c774f2a3d6385af Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sun, 5 Apr 2026 10:31:28 +0300 Subject: [PATCH 112/369] feat(tests): Add tests for Profile page --- frontend/src/pages/Profile/Profile.test.tsx | 72 +++ .../__snapshots__/Profile.test.tsx.snap | 420 ++++++++++++++++++ frontend/vite.config.ts | 1 + 3 files changed, 493 insertions(+) create mode 100644 frontend/src/pages/Profile/Profile.test.tsx create mode 100644 frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap diff --git a/frontend/src/pages/Profile/Profile.test.tsx b/frontend/src/pages/Profile/Profile.test.tsx new file mode 100644 index 0000000..636821a --- /dev/null +++ b/frontend/src/pages/Profile/Profile.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { useSelector } from 'react-redux'; +import { describe, it, expect, vi } from 'vitest'; +import { Profile } from './Profile'; +import { useMutation } from '@tanstack/react-query'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn(), +})); + +vi.mock('../../firebase', () => ({ + auth: { + currentUser: { uid: '123' }, + updateCurrentUser: vi.fn(), + }, +})); + +describe('Profile Component', () => { + + it('renders correctly and matches snapshot', () => { + vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('shows "Loading..." when user is null', () => { + vi.mocked(useSelector).mockReturnValue(null); + render(); + const loadingText = screen.getByText(/loading/i); + expect(loadingText).toBeInTheDocument(); + }); + + it('calls delete mutation on delete button click', () => { + const mockMutate = vi.fn(); + vi.mocked(useMutation).mockReturnValue({ mutate: mockMutate } as any); + vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + render(); + const deleteButton = screen.getByText("Видалити профіль"); + fireEvent.click(deleteButton); + expect(mockMutate).toHaveBeenCalled(); + }); + + it('displays user name and role', () => { + vi.mocked(useSelector).mockReturnValue({ displayName: 'Супер Хакер' }); + render(); + expect(screen.getByText('Супер Хакер')).toBeInTheDocument(); + expect(screen.getByText('Роль: Користувач')).toBeInTheDocument(); + }); + + it('displays correct contact details', () => { + vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + render(); + const emailLink = screen.getByRole('link', { name: 'hacker777@example.com' }); + expect(emailLink).toBeInTheDocument(); + expect(emailLink).toHaveAttribute('href', 'mailto:hacker777@example.com'); + expect(screen.getByText('hacker777')).toBeInTheDocument(); + }); + + it('displays tournament and team lists', () => { + vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + render(); + expect(screen.getByText('Турніри')).toBeInTheDocument(); + expect(screen.getByText('Команди')).toBeInTheDocument(); + expect(screen.getByText('Напишіть Ядро Лінукс')).toBeInTheDocument(); + expect(screen.getByText('Шалені програмісти')).toBeInTheDocument(); + expect(screen.getByText('Лінус Торвальдс')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap b/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap new file mode 100644 index 0000000..80ea95e --- /dev/null +++ b/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap @@ -0,0 +1,420 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Profile Component > renders correctly and matches snapshot 1`] = ` +
    +
    +
    +
    +
    +
    + + + + +
    +
    +

    + Тестер +

    + + Роль: Користувач + +
    +
    + + +
    +
    +
    +

    + СПОСОБИ ЗВ'ЯЗКУ +

    +
    + +
    + + Telegram: + + + @hacker777 + +
    +
    + + GitHub: + + + hacker777 + +
    +
    + + Discord: + + + @hacker777 + +
    +
    +
    +
    +
    +
    +

    + + Турніри +

    +
    +
    + + Напишіть Ядро Лінукс + + + + +
    +
    + + Напишіть свою мову програмування на рівні C++ + + + + +
    +
    + + Напишіть гру на JavaScript + + + + +
    +
    + + Напишіть чат-бота на Python + + + + +
    +
    + + Напишіть свою операційну систему + + + + +
    +
    +
    +
    +

    + + Команди +

    +
    +
    +
    + + + + + + + + + +
    + + Шалені програмісти + +
    +
    +
    + + + + + + + + + +
    + + Кодери мрії + +
    +
    +
    + + + + + + + + + +
    + + Лінус Торвальдс + +
    +
    +
    +
    +
    +
    +`; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1c510b1..d29333d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + setupFiles: ['./src/setupTests.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], From b6836561b3f5c9d8a192591ab4da0371f2f15733 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sun, 5 Apr 2026 10:46:58 +0300 Subject: [PATCH 113/369] feat(test): Add test for GetOrganizerRole Page --- frontend/src/App.tsx | 2 +- .../GetOganizerRole/GetOrganizerRole.test.tsx | 112 ++++++++++++ ...tOganizerRole.tsx => GetOrganizerRole.tsx} | 0 .../GetOrganizerRole.test.tsx.snap | 170 ++++++++++++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx rename frontend/src/pages/GetOganizerRole/{GetOganizerRole.tsx => GetOrganizerRole.tsx} (100%) create mode 100644 frontend/src/pages/GetOganizerRole/__snapshots__/GetOrganizerRole.test.tsx.snap diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3df78a9..185a90a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { Header } from "./components/Header"; import { Footer } from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; -import { GetOrganizerRole } from "./pages/GetOganizerRole/GetOganizerRole"; +import { GetOrganizerRole } from "./pages/GetOganizerRole/GetOrganizerRole"; import {Toaster} from 'react-hot-toast' export const App = () => { diff --git a/frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx b/frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx new file mode 100644 index 0000000..4b93860 --- /dev/null +++ b/frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { GetOrganizerRole } from './GetOrganizerRole'; +import { roleRequest } from '@/api/requests/roleRequest'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), +})); + +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/api/requests/roleRequest', () => ({ + roleRequest: vi.fn(), +})); + +vi.mock('lottie-react', () => ({ + default: () =>
    , +})); + +describe('GetOrganizerRole Component', () => { + const mockNavigate = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + }); + + it('renders correctly and matches snapshot', () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('shows error when user is missing', () => { + vi.mocked(useSelector).mockReturnValue(null); + const { container } = render(); + + fireEvent.submit(container.querySelector('form')!); + + expect(toast.error).toHaveBeenCalledWith('Користувач не знайдений або не авторизований!'); + }); + + it('submits form and navigates on success', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(roleRequest).mockResolvedValue(200); + + const { container } = render(); + + fireEvent.change(screen.getByLabelText(/ПІБ/i), { target: { value: 'Ivan Ivanov' } }); + fireEvent.change(screen.getByLabelText(/Email \/ Telegram/i), { target: { value: '@ivan' } }); + fireEvent.change(screen.getByLabelText(/Ваш вік/i), { target: { value: '25' } }); + fireEvent.change(screen.getByLabelText(/досвід/i), { target: { value: 'Some experience' } }); + fireEvent.change(screen.getByLabelText(/Чому ви хочете/i), { target: { value: 'Reason' } }); + fireEvent.change(screen.getByLabelText(/Що ви плануєте/i), { target: { value: 'Plans' } }); + + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(roleRequest).toHaveBeenCalledWith('user', '123', [ + { option_name: 'ПІБ', value: 'Ivan Ivanov' }, + { option_name: 'Контакти', value: '@ivan' }, + { option_name: 'Вік', value: '25' }, + { option_name: 'Досвід', value: 'Some experience' }, + { option_name: 'Причина', value: 'Reason' }, + { option_name: 'Плани', value: 'Plans' }, + ]); + }); + + expect(toast.success).toHaveBeenCalledWith('Заявку відправлено! Очікуйте на відповідь'); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + + it('shows duplicate error on 400 response', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(roleRequest).mockRejectedValue({ + response: { status: 400, data: { detail: 'Role requests already exists!' } } + }); + + const { container } = render(); + + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Ви вже подавали заявку на цю роль! Очікуйте на рішення.'); + }); + }); + + it('shows fallback error on API failure', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(roleRequest).mockRejectedValue(new Error('Network Error')); + + const { container } = render(); + + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Щось пішло не так. Спробуйте пізніше.'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx b/frontend/src/pages/GetOganizerRole/GetOrganizerRole.tsx similarity index 100% rename from frontend/src/pages/GetOganizerRole/GetOganizerRole.tsx rename to frontend/src/pages/GetOganizerRole/GetOrganizerRole.tsx diff --git a/frontend/src/pages/GetOganizerRole/__snapshots__/GetOrganizerRole.test.tsx.snap b/frontend/src/pages/GetOganizerRole/__snapshots__/GetOrganizerRole.test.tsx.snap new file mode 100644 index 0000000..7b3f411 --- /dev/null +++ b/frontend/src/pages/GetOganizerRole/__snapshots__/GetOrganizerRole.test.tsx.snap @@ -0,0 +1,170 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GetOrganizerRole Component > renders correctly and matches snapshot 1`] = ` +
    +
    +
    +
    +
    +
    +
    +

    + UGalaxy +

    +

    + STAR FOR LIFE +

    +

    + Заявка на роль організатора +

    +

    + Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну. +
    +
    + + Важливо: + + відповідайте чесно та детально — це підвищує ваші шанси. +

    +
    +
    +
    +
    +

    + + * — обов'язкове поле + +

    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + - -
    - -
    - - -
    - -
    - - -
    - - - -
    -
    -
    - ) -} - -export { GetOrganizerRole } \ No newline at end of file diff --git a/frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx b/frontend/src/pages/GetRole/GetOrganizerRole.test.tsx similarity index 98% rename from frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx rename to frontend/src/pages/GetRole/GetOrganizerRole.test.tsx index 4b93860..ae42af7 100644 --- a/frontend/src/pages/GetOganizerRole/GetOrganizerRole.test.tsx +++ b/frontend/src/pages/GetRole/GetOrganizerRole.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; -import { GetOrganizerRole } from './GetOrganizerRole'; +import { GetOrganizerRole } from './RoleRequestPage'; import { roleRequest } from '@/api/requests/roleRequest'; vi.mock('react-redux', () => ({ diff --git a/frontend/src/pages/GetOganizerRole/GetOrganizerRole.css b/frontend/src/pages/GetRole/RoleRequestPage.css similarity index 100% rename from frontend/src/pages/GetOganizerRole/GetOrganizerRole.css rename to frontend/src/pages/GetRole/RoleRequestPage.css diff --git a/frontend/src/pages/GetRole/RoleRequestPage.tsx b/frontend/src/pages/GetRole/RoleRequestPage.tsx new file mode 100644 index 0000000..1188042 --- /dev/null +++ b/frontend/src/pages/GetRole/RoleRequestPage.tsx @@ -0,0 +1,188 @@ +import React, { useState, useEffect } from 'react'; +import Lottie from 'lottie-react'; +import star from './star.json'; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; +import { roleRequest } from '@/api/requests/roleRequest'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import apiClient from '@/api/client'; + +interface Role { + name: string; + display_name: string; + description: string; +} + +const RoleRequestPage = () => { + const currentUser = useSelector((state: RootState) => state.user); + const navigate = useNavigate(); + + const [roles, setRoles] = useState([]); + const [selectedRole, setSelectedRole] = useState(null); + const [isLoadingRoles, setIsLoadingRoles] = useState(true); + + useEffect(() => { + const fetchRoles = async () => { + try { + const response = await apiClient.get('/roles/'); + const data: Role[] = response.data; + const filteredRoles = data.filter(role => role.name !== 'user'); + setRoles(filteredRoles); + if (filteredRoles.length > 0) { + setSelectedRole(filteredRoles[0]); + } + } catch (error) { + toast.error("Не вдалося завантажити список ролей"); + } finally { + setIsLoadingRoles(false); + } + }; + fetchRoles(); + }, []); + + const createRequests = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!currentUser?.id) { + toast.error("Користувач не знайдений або не авторизований!"); + return; + } + + if (!selectedRole) { + toast.error("Будь ласка, оберіть роль, на яку подаєте заявку!"); + return; + } + + const form = e.currentTarget; + const formData = new FormData(form); + + const info = [ + { option_name: "ПІБ", value: formData.get("fullName") as string }, + { option_name: "Контакти", value: formData.get("contact") as string }, + { option_name: "Вік", value: formData.get("age") as string }, + { option_name: "Досвід", value: formData.get("experience") as string }, + { option_name: "Причина", value: formData.get("reason") as string }, + { option_name: "Плани", value: formData.get("plans") as string }, + ]; + + try { + await roleRequest(selectedRole.name, currentUser.id, info); + toast.success("Заявку відправлено! Очікуйте на відповідь"); + form.reset(); + navigate("/"); + } catch (error: any) { + if (error.response && error.response.status === 400) { + if (error.response.data?.detail === "Role requests already exists!") { + toast.error("Ви вже подавали заявку на цю роль! Очікуйте на рішення."); + return; + } + } + toast.error("Щось пішло не так. Спробуйте пізніше."); + } + } + + const inputClasses = "peer w-full px-[18px] pt-6 pb-2 border border-slate-200 rounded-xl bg-[#fafafa] text-sm text-gray-800 transition-all focus:outline-none focus:border-[#7b00ff] focus:bg-white focus:shadow-[0_0_0_3px_rgba(123,0,255,0.1)] placeholder-transparent"; + const labelClasses = "absolute left-[18px] top-1.5 text-[11px] font-medium text-[#7b00ff] pointer-events-none transition-all peer-placeholder-shown:top-[18px] peer-placeholder-shown:text-sm peer-placeholder-shown:text-gray-400 peer-placeholder-shown:font-normal peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-[#7b00ff] peer-focus:font-medium"; + + return( +
    +
    +
    +
    + +
    +

    UGalaxy

    +

    STAR FOR LIFE

    + +

    + Заявка на роль {selectedRole ? selectedRole.display_name.toLowerCase() : '...'} +

    +

    + {selectedRole ? selectedRole.description : 'Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну.'} +

    + Важливо: відповідайте чесно та детально — це підвищує ваші шанси. +

    +
    +
    + +
    +
    +
    +

    Оберіть бажану роль:

    + {isLoadingRoles ? ( +

    Завантаження ролей...

    + ) : ( +
    + {roles.map((role) => { + const isActive = selectedRole?.name === role.name; + return ( + + ); + })} +
    + )} +
    + +

    * — обов'язкове поле

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    +
    +
    +
    + ) +} + +export { RoleRequestPage } \ No newline at end of file diff --git a/frontend/src/pages/GetOganizerRole/__snapshots__/GetOrganizerRole.test.tsx.snap b/frontend/src/pages/GetRole/__snapshots__/GetOrganizerRole.test.tsx.snap similarity index 100% rename from frontend/src/pages/GetOganizerRole/__snapshots__/GetOrganizerRole.test.tsx.snap rename to frontend/src/pages/GetRole/__snapshots__/GetOrganizerRole.test.tsx.snap diff --git a/frontend/src/pages/GetOganizerRole/star.json b/frontend/src/pages/GetRole/star.json similarity index 100% rename from frontend/src/pages/GetOganizerRole/star.json rename to frontend/src/pages/GetRole/star.json From 6af341facda0524b2741180cdecf61575b07e80f Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sun, 5 Apr 2026 17:37:35 +0300 Subject: [PATCH 115/369] test: rewrite tests for RoleRequest page --- frontend/.gitignore | 2 +- .../pages/GetRole/GetOrganizerRole.test.tsx | 112 ------------ .../src/pages/GetRole/RoleRequst.test.tsx | 162 ++++++++++++++++++ 3 files changed, 163 insertions(+), 113 deletions(-) delete mode 100644 frontend/src/pages/GetRole/GetOrganizerRole.test.tsx create mode 100644 frontend/src/pages/GetRole/RoleRequst.test.tsx diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..54f07af 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -21,4 +21,4 @@ dist-ssr *.ntvs* *.njsproj *.sln -*.sw? +*.sw? \ No newline at end of file diff --git a/frontend/src/pages/GetRole/GetOrganizerRole.test.tsx b/frontend/src/pages/GetRole/GetOrganizerRole.test.tsx deleted file mode 100644 index ae42af7..0000000 --- a/frontend/src/pages/GetRole/GetOrganizerRole.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; -import toast from 'react-hot-toast'; -import { GetOrganizerRole } from './RoleRequestPage'; -import { roleRequest } from '@/api/requests/roleRequest'; - -vi.mock('react-redux', () => ({ - useSelector: vi.fn(), -})); - -vi.mock('react-router-dom', () => ({ - useNavigate: vi.fn(), -})); - -vi.mock('react-hot-toast', () => ({ - default: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('@/api/requests/roleRequest', () => ({ - roleRequest: vi.fn(), -})); - -vi.mock('lottie-react', () => ({ - default: () =>
    , -})); - -describe('GetOrganizerRole Component', () => { - const mockNavigate = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useNavigate).mockReturnValue(mockNavigate); - }); - - it('renders correctly and matches snapshot', () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - - it('shows error when user is missing', () => { - vi.mocked(useSelector).mockReturnValue(null); - const { container } = render(); - - fireEvent.submit(container.querySelector('form')!); - - expect(toast.error).toHaveBeenCalledWith('Користувач не знайдений або не авторизований!'); - }); - - it('submits form and navigates on success', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(roleRequest).mockResolvedValue(200); - - const { container } = render(); - - fireEvent.change(screen.getByLabelText(/ПІБ/i), { target: { value: 'Ivan Ivanov' } }); - fireEvent.change(screen.getByLabelText(/Email \/ Telegram/i), { target: { value: '@ivan' } }); - fireEvent.change(screen.getByLabelText(/Ваш вік/i), { target: { value: '25' } }); - fireEvent.change(screen.getByLabelText(/досвід/i), { target: { value: 'Some experience' } }); - fireEvent.change(screen.getByLabelText(/Чому ви хочете/i), { target: { value: 'Reason' } }); - fireEvent.change(screen.getByLabelText(/Що ви плануєте/i), { target: { value: 'Plans' } }); - - fireEvent.submit(container.querySelector('form')!); - - await waitFor(() => { - expect(roleRequest).toHaveBeenCalledWith('user', '123', [ - { option_name: 'ПІБ', value: 'Ivan Ivanov' }, - { option_name: 'Контакти', value: '@ivan' }, - { option_name: 'Вік', value: '25' }, - { option_name: 'Досвід', value: 'Some experience' }, - { option_name: 'Причина', value: 'Reason' }, - { option_name: 'Плани', value: 'Plans' }, - ]); - }); - - expect(toast.success).toHaveBeenCalledWith('Заявку відправлено! Очікуйте на відповідь'); - expect(mockNavigate).toHaveBeenCalledWith('/'); - }); - - it('shows duplicate error on 400 response', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(roleRequest).mockRejectedValue({ - response: { status: 400, data: { detail: 'Role requests already exists!' } } - }); - - const { container } = render(); - - fireEvent.submit(container.querySelector('form')!); - - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Ви вже подавали заявку на цю роль! Очікуйте на рішення.'); - }); - }); - - it('shows fallback error on API failure', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(roleRequest).mockRejectedValue(new Error('Network Error')); - - const { container } = render(); - - fireEvent.submit(container.querySelector('form')!); - - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Щось пішло не так. Спробуйте пізніше.'); - }); - }); -}); \ No newline at end of file diff --git a/frontend/src/pages/GetRole/RoleRequst.test.tsx b/frontend/src/pages/GetRole/RoleRequst.test.tsx new file mode 100644 index 0000000..6013acd --- /dev/null +++ b/frontend/src/pages/GetRole/RoleRequst.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { RoleRequestPage } from './RoleRequestPage'; +import { roleRequest } from '@/api/requests/roleRequest'; +import apiClient from '@/api/client'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), +})); + + +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + + +vi.mock('@/api/requests/roleRequest', () => ({ + roleRequest: vi.fn(), +})); + +vi.mock('@/api/client', () => ({ + default: { + get: vi.fn(), + }, +})); + +vi.mock('lottie-react', () => ({ + default: () =>
    , +})); + +describe('RoleRequestPage Component', () => { + const mockNavigate = vi.fn(); + + const mockRoles = [ + { name: 'admin', display_name: 'Administrator', description: 'Admin role' }, + { name: 'moderator', display_name: 'Moderator', description: 'Mod role' }, + { name: 'user', display_name: 'User', description: 'Regular user' }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + vi.mocked(apiClient.get).mockResolvedValue({ data: mockRoles }); + }); + + it('renders loading state initially', () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + render(); + + expect(screen.getByText('Завантаження ролей...')).toBeInTheDocument(); + }); + + it('fetches and displays roles correctly, filtering out "user"', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + render(); + await waitFor(() => { + expect(screen.getByText('Administrator')).toBeInTheDocument(); + expect(screen.getByText('Moderator')).toBeInTheDocument(); + }); + expect(screen.queryByText('User')).not.toBeInTheDocument(); + + expect(screen.getByText('Заявка на роль administrator')).toBeInTheDocument(); + }); + + it('shows error toast if role fetching fails', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(apiClient.get).mockRejectedValue(new Error('Network Error')); + + render(); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Не вдалося завантажити список ролей'); + }); + }); + + it('shows error when user is missing on submit', async () => { + vi.mocked(useSelector).mockReturnValue(null); + const { container } = render(); + + await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); + + fireEvent.submit(container.querySelector('form')!); + + expect(toast.error).toHaveBeenCalledWith('Користувач не знайдений або не авторизований!'); + }); + + it('submits form as Tolka and navigates on success', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(roleRequest).mockResolvedValue(200); + + const { container } = render(); + + await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Moderator')); + + fireEvent.change(screen.getByLabelText(/ПІБ \*/i), { target: { value: 'Толька' } }); + fireEvent.change(screen.getByLabelText(/Email \/ Telegram \*/i), { target: { value: '@tolka_boss' } }); + fireEvent.change(screen.getByLabelText(/Ваш вік \*/i), { target: { value: '22' } }); + fireEvent.change(screen.getByLabelText(/Чи маєте релевантний досвід\? \*/i), { target: { value: 'Досвід бути Толькою' } }); + fireEvent.change(screen.getByLabelText(/Чому ви хочете отримати цю роль\? \*/i), { target: { value: 'Бо я Толька' } }); + fireEvent.change(screen.getByLabelText(/Що ви плануєте робити на цій ролі\? \*/i), { target: { value: 'Наводити порядки' } }); + + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(roleRequest).toHaveBeenCalledWith('moderator', '123', [ + { option_name: 'ПІБ', value: 'Толька' }, + { option_name: 'Контакти', value: '@tolka_boss' }, + { option_name: 'Вік', value: '22' }, + { option_name: 'Досвід', value: 'Досвід бути Толькою' }, + { option_name: 'Причина', value: 'Бо я Толька' }, + { option_name: 'Плани', value: 'Наводити порядки' }, + ]); + }); + + expect(toast.success).toHaveBeenCalledWith('Заявку відправлено! Очікуйте на відповідь'); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + + it('shows duplicate error on 400 response', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(roleRequest).mockRejectedValue({ + response: { status: 400, data: { detail: 'Role requests already exists!' } } + }); + + const { container } = render(); + + await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); + + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Ви вже подавали заявку на цю роль! Очікуйте на рішення.'); + }); + }); + + it('shows fallback error on API failure', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(roleRequest).mockRejectedValue(new Error('Network Error')); + + const { container } = render(); + + await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); + + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Щось пішло не так. Спробуйте пізніше.'); + }); + }); +}); \ No newline at end of file From 59e6721df382d97f175d366f231995d3514635e5 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 7 Apr 2026 14:59:48 +0300 Subject: [PATCH 116/369] feat(tournamentPage): Add requests to backend --- .../pages/TournamentPage/TournamentPage.tsx | 331 ++++++++---------- 1 file changed, 145 insertions(+), 186 deletions(-) diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx index 78e91d4..70f634d 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -1,7 +1,18 @@ import { useState, useRef, useEffect, type ReactNode } from "react"; -import { Header } from "../../components/Header"; +import { useParams } from "react-router-dom"; +import apiClient from "@/api/client"; import { Hero } from "../../components/Hero"; +// --- ТИПІЗАЦІЯ --- +interface TournamentData { + title: string; + description: string; + start_date: string; + reg_start: string; + reg_end: string; + max_team: number; +} + const TABS = [ { id: "desc", label: "Опис завдання" }, { id: "looking", label: "Шукають команду" }, @@ -12,136 +23,49 @@ const TABS = [ type TabId = (typeof TABS)[number]["id"]; type TourneyStatus = "registration" | "active" | "waiting" | "finished"; +// --- ІКОНКИ --- const RegistrationIcon = () => ( - - - - - - + ); - const ActiveIcon = () => ( - - - + ); - const WaitingIcon = () => ( - - - - + ); - const FinishedIcon = () => ( - - - - + ); - const GameDevIcon = () => ( - - - - + ); - const ClockIcon = () => ( - - - - + ); - const CheckCircleIcon = () => ( - - - - + +); +const AlertIcon = () => ( + +); +const SpinnerIcon = () => ( + ); -const STATUS_CONFIG: Record< - TourneyStatus, - { label: string; className: string; icon: ReactNode } -> = { +const STATUS_CONFIG: Record = { registration: { label: "Реєстрація", - className: "bg-blue-400 text-blue-950 shadow-blue-400/20", + className: "bg-blue-400/90 text-blue-950 shadow-[0_0_20px_rgba(96,165,250,0.4)]", icon: , }, active: { label: "Активно", - className: "bg-emerald-400 text-emerald-950 shadow-emerald-400/20", + className: "bg-emerald-400/90 text-emerald-950 shadow-[0_0_20px_rgba(52,211,153,0.4)]", icon: , }, waiting: { label: "Очікування результатів", - className: "bg-accent text-dark-theme shadow-accent/20", + className: "bg-accent/90 text-dark-theme shadow-[0_0_20px_rgba(250,204,21,0.4)]", icon: , }, finished: { @@ -152,46 +76,112 @@ const STATUS_CONFIG: Record< }; export const TournamentPage = () => { - const currentStatus: TourneyStatus = "active"; + const { id } = useParams<{ id: string }>(); + + const [tournament, setTournament] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchTournament = async () => { + if (!id) { + setError("ID турніру не знайдено"); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + // Використовуємо apiClient замість fetch + const response = await apiClient.get(`/tournaments/${id}`); + setTournament(response.data); + setError(null); + } catch (err: any) { + // Обробка помилок відповідно до Swagger (422 Validation Error або загальні) + const errorMessage = + err.response?.data?.detail?.[0]?.msg || + err.response?.data?.message || + "Не вдалося завантажити інформацію про турнір."; + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + fetchTournament(); + }, [id]); + + const currentStatus: TourneyStatus = "active"; const statusInfo = STATUS_CONFIG[currentStatus]; + // Стан завантаження + if (isLoading) { + return ( +
    + +

    + Завантаження турніру... +

    +
    + ); + } + + // Стан помилки + if (error || !tournament) { + return ( +
    +
    +
    + +
    +

    Ой, халепа!

    +

    {error || "Турнір не знайдено"}

    + +
    +
    + ); + } + return ( -
    -
    +
    +
    -
    +
    {statusInfo.icon} {statusInfo.label}
    -
    +
    GameDev & Алгоритми
    - - Slovo Game Jam - +

    + {tournament.title} +

    -
    +
    -
    - -
    +
    + +
    - -
    @@ -199,23 +189,23 @@ export const TournamentPage = () => { } /> - +
    ); }; const StatItem = ({ value, label }: { value: string; label: string }) => ( -
    - +
    + {value} - + {label}
    ); -const TournamentMainContent = () => { +const TournamentMainContent = ({ tournament }: { tournament: TournamentData }) => { const [activeTab, setActiveTab] = useState("desc"); const [lineStyle, setLineStyle] = useState({ left: 0, width: 0 }); const tabsRef = useRef<(HTMLButtonElement | null)[]>([]); @@ -243,7 +233,7 @@ const TournamentMainContent = () => { tabsRef.current[index] = el; }} onClick={() => setActiveTab(tab.id)} - className={`font-quicksand font-bold text-[22px] cursor-pointer relative z-10 transition-colors duration-300 ${ + className={`font-quicksand font-bold text-[20px] md:text-[22px] cursor-pointer relative z-10 transition-colors duration-300 px-2 py-1 ${ activeTab === tab.id ? "text-primary" : "text-slate-400 hover:text-primary/70" @@ -253,7 +243,7 @@ const TournamentMainContent = () => { ))}
    {
    -
    - {activeTab === "desc" && } +
    + {activeTab === "desc" && } {activeTab !== "desc" && }
    ); }; -const DescriptionTab = () => ( -
    +const DescriptionTab = ({ description }: { description: string }) => ( +
    -

    +

    Що потрібно зробити?

    -

    - Ваша мета — створити ядро аналог лінукс, створити ядро аналог лінукс, - створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро - аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, - створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро - аналог лінукс, створити ядро аналог лінукс +

    + {description}

    + {/* Заглушка під статичні дані, якщо згодом з'являться на бекенді */}
    -

    - Ключові вимоги: +

    +
    + +
    + Ключові вимоги:

    -
      -
    • -
      -

      - створити ядро аналог лінукс, створити ядро аналог лінукс, створити - ядро аналог лінукс, -

      -
    • -
    • -
      -

      створити ядро аналог лінукс,

      -
    • -
    • -
      -

      створити ядро аналог лінукс, створити ядро аналог лінукс,

      +
        + {/* Приклад статичного контенту */} +
      • +
        +

        Створити інноваційний проект з використанням GameDev підходів.

    - -
    -

    - Стек технологій: -

    -

    - Жодних жорстких обмежень! Всього лиш вимога писати на перфокатрі, та - використовуючи два резистора і пачку мівіни змусити це чудо запуститись. -

    - -
    - {["Перфокарти", "Два резистора", "Пачка мівіни"].map((tech) => ( - - {tech} - - ))} -
    -
    ); const PlaceholderTab = () => ( -
    -
    +
    +
    -

    - Скоро буде... +

    + В розробці...

    -

    - Інформація для цього розділу наразі готується. Повертайтеся трохи згодом! +

    + Інформація для цього розділу наразі готується. Повертайтеся трохи згодом, ми вже працюємо над цим!

    -); +); \ No newline at end of file From c9b4a2a905724f66cedff02d9426b7501b7d98b5 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 7 Apr 2026 22:14:24 +0300 Subject: [PATCH 117/369] feat(notifications&dropMenu): Add drop menu for profilw options and plachoed for notification --- frontend/src/components/Header.tsx | 51 +++++------ .../src/components/NotificationsDropdown.tsx | 84 +++++++++++++++++++ frontend/src/components/ProfileDropdown.tsx | 69 +++++++++++++++ 3 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/NotificationsDropdown.tsx create mode 100644 frontend/src/components/ProfileDropdown.tsx diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 84e77a9..c2e990a 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,19 +1,19 @@ import { Link, NavLink } from "react-router-dom"; -import { auth } from "../firebase"; -import { Activity, useState } from "react"; -import { store, type RootState } from "../store"; import { useSelector } from "react-redux"; +import { type RootState } from "../store"; + +import { ProfileDropdown } from "./ProfileDropdown"; +import { NotificationsDropdown } from "./NotificationsDropdown"; export const Header = () => { const navItems = [ { path: "/tournaments", label: "Турніри" }, - { path: "/projects", label: "Проєкти" }, - { path: "/join", label: "Як долучитись" }, - { path: "/rating", label: "Рейтинг" }, + { path: "/aboutUs", label: "Про нас" }, + { path: "/support", label: "Чим ви можете допомогти" }, + { path: "/contact", label: "Контакти" }, ]; + const user = useSelector((s: RootState) => s.user); - const [isMenuOpen, setIsMenuOpen] = useState(false); - const toggleMenu = () => setIsMenuOpen(!isMenuOpen); return (
    @@ -31,7 +31,8 @@ export const Header = () => { key={item.path} to={item.path} className={({ isActive }) => - `group relative font-semibold text-[16px] py-2 transition-colors duration-300 ${isActive ? "text-accent" : "text-white hover:text-accent" + `group relative font-semibold text-[16px] py-2 transition-colors duration-300 ${ + isActive ? "text-accent" : "text-white hover:text-accent" }` } > @@ -39,8 +40,9 @@ export const Header = () => { <> {item.label} )} @@ -48,24 +50,17 @@ export const Header = () => { ))} - {user ?
    - - Профіль + {user ? ( +
    + + +
    + ) : ( + + Увійти - - -
      -
    • - - Вийти - -
    • -
    -
    -
    : - Увійти - } + )}
    ); -}; +}; \ No newline at end of file diff --git a/frontend/src/components/NotificationsDropdown.tsx b/frontend/src/components/NotificationsDropdown.tsx new file mode 100644 index 0000000..0548946 --- /dev/null +++ b/frontend/src/components/NotificationsDropdown.tsx @@ -0,0 +1,84 @@ +import { useState, useRef, useEffect } from "react"; +// import { useSelector } from "react-redux"; +// import { type RootState } from "../store"; + +export const NotificationsDropdown = () => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const notifications = [ + { body: "🚀 Турнір 'Зимова битва' розпочнеться за 2 години!" }, + { body: "✅ Ваша заявка на проєкт Star for Life успішно прийнята. Вітаємо в команді!" }, + { body: "👑 Адміністратор надав вам нову роль. Тепер ви можете створювати проєкти." }, + { body: "🔔 Це просто тестове повідомлення, щоб перевірити, як працює скрол у менюшці, коли тексту дуже багато і повідомлень теж багато." } + ]; + + // const notifications = useSelector((s: RootState) => s.user?.notifications || []); + + const toggleMenu = () => setIsOpen(!isOpen); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
    + + + {isOpen && ( +
    + +
    +

    Сповіщення

    + + {notifications.length} нових + +
    + +
    + {notifications.length > 0 ? ( + notifications.map((notification, index) => ( +
    +

    + {notification.body} +

    +
    + )) + ) : ( +
    + + + +

    Немає нових сповіщень

    +
    + )} +
    + +
    + )} +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ProfileDropdown.tsx b/frontend/src/components/ProfileDropdown.tsx new file mode 100644 index 0000000..bb6bec5 --- /dev/null +++ b/frontend/src/components/ProfileDropdown.tsx @@ -0,0 +1,69 @@ +import { useState, useRef, useEffect } from "react"; +import { Link } from "react-router-dom"; + +export const ProfileDropdown = () => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const toggleMenu = () => setIsOpen(!isOpen); + const closeMenu = () => setIsOpen(false); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
    + + {isOpen && ( +
    + + + + + Мій профіль + + +
    + + + + + + Вийти + +
    + )} +
    + ); +}; \ No newline at end of file From b4a3910bc02fcd5236c10f7f802ddd54c52ba75d Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 7 Apr 2026 22:26:48 +0300 Subject: [PATCH 118/369] feat(Contact): Add contsct page --- frontend/src/App.tsx | 2 + frontend/src/pages/Contact/Contact.tsx | 151 +++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 frontend/src/pages/Contact/Contact.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2efe87f..0ce47fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Profile } from "./pages/Profile/Profile"; import { TournamentsPage } from "./pages/TournamentsPage/TournamentsPage"; import { TournamentPage } from "./pages/TournamentPage/TournamentPage"; import { Page404 } from "./pages/Page404/Page404"; +import { ContactPage } from "./pages/Contact/Contact"; import SignIn from "./pages/Auth/SignIn"; import SignUp from "./pages/Auth/SignUp"; import SignOut from "./pages/Auth/SignOut"; @@ -37,6 +38,7 @@ export const App = () => { } /> } /> + } /> } /> diff --git a/frontend/src/pages/Contact/Contact.tsx b/frontend/src/pages/Contact/Contact.tsx new file mode 100644 index 0000000..41848a6 --- /dev/null +++ b/frontend/src/pages/Contact/Contact.tsx @@ -0,0 +1,151 @@ +import React from 'react'; + +const ContactPage: React.FC = () => { + const socials = [ + { + name: 'Instagram', + url: 'https://www.instagram.com/starforlifeukraine', + color: 'bg-gradient-to-tr from-yellow-400 via-pink-500 to-purple-500', + icon: ( + + + + + + ), + }, + { + name: 'Facebook', + url: 'https://www.facebook.com/starforlifeua', + color: 'bg-blue-600', + icon: ( + + + + ), + }, + { + name: 'LinkedIn', + url: 'https://www.linkedin.com/company/starforlifeua', + color: 'bg-blue-800', + icon: ( + + + + + + ), + }, + { + name: 'YouTube', + url: 'https://www.youtube.com/@starforlifeua', + color: 'bg-red-600', + icon: ( + + + + + ), + }, + ]; + + return ( +
    +
    + +

    + КОНТАКТИ +

    +

    + Маєш питання, ідеї або просто хочеш привітатись? Ми завжди відкриті до спілкування. Обирай зручний спосіб! +

    +
    +
    +
    +
    +
    + + Організація + +

    Наші дані

    + +
    +
    +
    + + + +
    +
    +

    Електронна пошта

    + + team@starforlife.org.ua + +
    +
    + +
    +
    + + + +
    +
    +

    ЄДРПОУ / Реєстрація

    +

    44977372

    +
    +
    + +
    +
    + + + +
    +
    +

    Дата заснування

    +

    30.01.2023

    +
    +
    +
    +
    +
    + +
    +
    + +
    + + Ком'юніті + +

    Ми в соцмережах

    +

    + Підписуйся, щоб не пропустити нові турніри, челенджі та корисний контент для твого розвитку. +

    + +
    + {socials.map((social) => ( + +
    + {social.icon} +
    + {social.name} +
    + ))} +
    +
    +
    + +
    +
    +
    + ); +}; + +export { ContactPage }; \ No newline at end of file From 41d69ba54bdde3af1ea70dd4ac431316a41d0662 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 13:19:51 +0300 Subject: [PATCH 119/369] chore(contacts): Move telegeram and discord links to contact page --- frontend/src/pages/Contact/Contact.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Contact/Contact.tsx b/frontend/src/pages/Contact/Contact.tsx index 41848a6..ea2bcab 100644 --- a/frontend/src/pages/Contact/Contact.tsx +++ b/frontend/src/pages/Contact/Contact.tsx @@ -14,6 +14,26 @@ const ContactPage: React.FC = () => { ), }, + { + name: 'Telegram', + url: 'https://t.me/starforlifeukraine', + color: 'bg-[#229ED9]', + icon: ( + + + + ), + }, + { + name: 'Discord', + url: 'https://discord.gg/JQ9B7NgCM7', + color: 'bg-[#5865F2]', + icon: ( + + + + ), + }, { name: 'Facebook', url: 'https://www.facebook.com/starforlifeua', @@ -52,7 +72,6 @@ const ContactPage: React.FC = () => { return (
    -

    КОНТАКТИ

    From 58e937afbcedb373dbd7156be2f62d2401e89b78 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 13:41:08 +0300 Subject: [PATCH 120/369] feat(support&about): Add Support pasge and about page --- frontend/src/App.tsx | 4 + frontend/src/pages/AboutUs/AboutUs.tsx | 117 ++++++++++++++++++ .../src/pages/SupportPage/SupportPage.tsx | 109 ++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 frontend/src/pages/AboutUs/AboutUs.tsx create mode 100644 frontend/src/pages/SupportPage/SupportPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ce47fc..beffb2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { TournamentsPage } from "./pages/TournamentsPage/TournamentsPage"; import { TournamentPage } from "./pages/TournamentPage/TournamentPage"; import { Page404 } from "./pages/Page404/Page404"; import { ContactPage } from "./pages/Contact/Contact"; +import { AboutUs } from "./pages/AboutUs/AboutUs"; +import { SupportPage } from "./pages/SupportPage/SupportPage"; import SignIn from "./pages/Auth/SignIn"; import SignUp from "./pages/Auth/SignUp"; import SignOut from "./pages/Auth/SignOut"; @@ -39,6 +41,8 @@ export const App = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/pages/AboutUs/AboutUs.tsx b/frontend/src/pages/AboutUs/AboutUs.tsx new file mode 100644 index 0000000..78dee18 --- /dev/null +++ b/frontend/src/pages/AboutUs/AboutUs.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +const AboutUs: React.FC = () => { + const pillars = [ + { + title: 'Освітня', + description: 'Розширюємо знання та навички для наступного покоління.', + color: 'bg-blue-500', + lightBg: 'bg-blue-50', + textColor: 'text-blue-500', + icon: ( + + + + ), + }, + { + title: 'Ментальна', + description: 'Виховуємо стійкість, впевненість та благополуччя.', + color: 'bg-purple-500', + lightBg: 'bg-purple-50', + textColor: 'text-purple-500', + icon: ( + + + + ), + }, + { + title: 'Гуманітарна', + description: 'Підтримуємо дітей України у найскрутніші часи.', + color: 'bg-teal-500', + lightBg: 'bg-teal-50', + textColor: 'text-teal-500', + icon: ( + + + + ), + }, + ]; + + return ( +
    +
    +

    + ХТО МИ +

    +
    + Наше Бачення +

    + Майбутнє, де кожна дитина з малозабезпечених сімей в Україні матиме рівний доступ до можливостей, що змінюють життя, через ІТ-освіту, емоційну стійкість та творчий розвиток. +

    +
    +
    + +
    +
    +
    +
    + + Наша Історія + +

    Від сміливого бачення до реальних дій

    +
    +
    +

    + У 2022 році, серед викликів та негараздів, з якими зіткнулася Україна, народилася ініціатива – Star for Life Ukraine. +

    +
    +
    +
    +

    + Те, що починалося як бачення, швидко перетворилося на дії, і до початку 2023 року ми перетворилися на повноцінний благодійний фонд. Наше коріння сягає світового бренду «Star for Life», що був заснований у 2005 році з метою розширення можливостей дітей у всьому світі. Маючи представництва на різних континентах, ініціатива позитивно вплинула на життя понад 500 000 дітей. +

    +

    + Наша подорож в Україні розпочалася з нагальної потреби підтримати молодих людей, які постраждали від постійних викликів у регіоні. Оскільки понад 5 мільйонів дітей потребують підтримки, наша місія була чіткою: забезпечити їх інструментами, освітою та можливостями, на які вони заслуговують. З наших скромних початків ми перетворилися на силу, яка змінює життя кожної дитини крок за кроком. +

    +
    +
    +
    + +
    +
    +
    + +
    + + Діяльність + +

    Що Ми Робимо

    +

    + Тисячі дітей з малозабезпечених сімей в Україні, особливо ті, хто постраждав від війни, не мають доступу до якісної освіти. «Star for Life Ukraine» працює над тим, щоб змінити це, кидаючи виклик системам та забезпечуючи кожній дитині безпечний розвиток. +

    +
    + +
    + {pillars.map((pillar, index) => ( +
    +
    + {pillar.icon} +
    +

    {pillar.title}

    +

    + {pillar.description} +

    +
    + ))} +
    +
    + +
    +
    + ); +}; + +export { AboutUs }; \ No newline at end of file diff --git a/frontend/src/pages/SupportPage/SupportPage.tsx b/frontend/src/pages/SupportPage/SupportPage.tsx new file mode 100644 index 0000000..b696263 --- /dev/null +++ b/frontend/src/pages/SupportPage/SupportPage.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +const SupportPage: React.FC = () => { + const supportOptions = [ + { + title: 'Задонатити', + description: 'Твій фінансовий внесок безпосередньо допомагає забезпечувати дітей якісною освітою, психологічною підтримкою та необхідними ресурсами.', + buttonText: 'Зробити внесок', + link: 'https://www.sflua.org/uk/donate-1', + gradient: 'from-rose-400 to-pink-500', + iconBg: 'bg-rose-100', + iconColor: 'text-rose-500', + btnHover: 'hover:shadow-rose-500/30', + icon: ( + + + + ), + }, + { + title: 'Партнерство', + description: 'Стань нашим партнером! Разом ми зможемо реалізувати масштабні проекти, залучити більше ресурсів та створити сталі зміни в суспільстві.', + buttonText: 'Стати партнером', + link: 'mailto:team@starforlife.org.ua?subject=Potential%20partnership', + gradient: 'from-blue-500 to-indigo-600', + iconBg: 'bg-blue-100', + iconColor: 'text-blue-600', + btnHover: 'hover:shadow-blue-500/30', + icon: ( + + + + ), + }, + { + title: 'Волонтерство', + description: 'Поділися своїм часом, знаннями та навичками. Твоя особиста участь та підтримка можуть стати вирішальними для майбутнього дитини.', + buttonText: 'Стати волонтером', + link: 'https://www.sflua.org/uk/volunteer', // TODO: Встав сюди посилання на анкету волонтера + gradient: 'from-emerald-400 to-teal-500', + iconBg: 'bg-emerald-100', + iconColor: 'text-emerald-600', + btnHover: 'hover:shadow-emerald-500/30', + icon: ( + + + + ), + }, + ]; + + return ( +
    +
    +
    +
    + + + Твоя підтримка важлива + +

    + ПІДТРИМАТИ НАС +

    +

    + Ось кілька способів, як ви можете нас підтримати та долучитися до створення кращого майбутнього для дітей України. +

    +
    + +
    +
    + {supportOptions.map((option, index) => ( +
    +
    +
    + {option.icon} +
    +

    {option.title}

    +

    + {option.description} +

    +
    + + {option.buttonText} + +
    + ))} +
    +
    + +
    +
    +

    + Кожна гривня, кожна година вашого часу та кожна спільна ініціатива наближають нас до мети. Дякуємо, що ви з нами! 💙💛 +

    +
    +
    +
    + ); +}; + +export { SupportPage }; \ No newline at end of file From b1601bba11de27ec0eb9d0d07973af3725701419 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 13:47:18 +0300 Subject: [PATCH 121/369] chore(footer): Delete some unuse links --- frontend/src/components/Footer.tsx | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 24d1ff7..ed90058 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -32,14 +32,6 @@ export const Footer = () => { Всі завдання
  • -
  • - - Рейтинг учасників - -
  • {

    - Спільнота + Інформація

  • ); -}; +}; \ No newline at end of file From 86e5ac1b648b62998c8b747e6ec317e4abb455d7 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 13:55:46 +0300 Subject: [PATCH 122/369] feat(FAQ): Add FAQ page and some chnges in fotter --- frontend/src/App.tsx | 2 + frontend/src/components/Footer.tsx | 8 +- frontend/src/pages/FaqPage/FaqPage.tsx | 125 +++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/FaqPage/FaqPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index beffb2b..b91216c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { Page404 } from "./pages/Page404/Page404"; import { ContactPage } from "./pages/Contact/Contact"; import { AboutUs } from "./pages/AboutUs/AboutUs"; import { SupportPage } from "./pages/SupportPage/SupportPage"; +import { FaqPage } from "./pages/FaqPage/FaqPage"; import SignIn from "./pages/Auth/SignIn"; import SignUp from "./pages/Auth/SignUp"; import SignOut from "./pages/Auth/SignOut"; @@ -43,6 +44,7 @@ export const App = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index ed90058..1ef9499 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -26,10 +26,10 @@ export const Footer = () => {
    • - Всі завдання + Всі турніри
    • @@ -58,7 +58,7 @@ export const Footer = () => {
      • Про нас @@ -66,7 +66,7 @@ export const Footer = () => {
      • Контакти diff --git a/frontend/src/pages/FaqPage/FaqPage.tsx b/frontend/src/pages/FaqPage/FaqPage.tsx new file mode 100644 index 0000000..32bd585 --- /dev/null +++ b/frontend/src/pages/FaqPage/FaqPage.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; + +const FaqPage: React.FC = () => { + const [openIndex, setOpenIndex] = useState(null); + + const faqs = [ + { + question: 'Хто ми і що це за платформа?', + answer: 'Це платформа від благодійного фонду Star for Life Ukraine. Ми допомагаємо молоді розвиватися через освіту, менторство та практику. Тут можна брати участь у турнірах, знайомитися з однодумцями та прокачувати свої навички.', + }, + { + question: 'Чому все безкоштовно?', + answer: 'Жодного підступу 🙂 Участь безкоштовна, бо платформу підтримують партнери та донори. Наша мета — дати рівні можливості кожному.', + }, + { + question: 'Які тут турніри?', + answer: 'Є IT-турніри, кіберспорт, дизайн, математика, творчі конкурси та інші напрямки. Кожен знайде щось для себе.', + }, + { + question: 'Як взяти участь у турнірі?', + answer: 'Все просто: зареєструйся, обери турнір у розділі «Турніри» та натисни «Взяти участь». У деяких турнірах потрібна команда — її можна створити або приєднатися до існуючої.', + }, + { + question: 'Що я отримаю від участі?', + answer: 'Практичний досвід, розвиток навичок, роботу в команді та нові знайомства. Також є можливість виграти призи: гаджети, курси, менторство або мерч.', + }, + { + question: 'Як змінити роль на платформі?', + answer: 'Спочатку всі мають роль «Користувач». Щоб отримати іншу роль, подай заявку на сторінці «Отримання ролі» — ми її перевіримо.', + }, + { + question: 'Що робити, якщо виникла проблема?', + answer: 'Якщо щось не працює (реєстрація, команда тощо) — звернись у підтримку через розділ «Контакти». Ми обов\'язково допоможемо.', + }, + { + question: 'Чи є обмеження за віком?', + answer: 'Більшість турнірів орієнтовані на підлітків і студентів, але умови можуть відрізнятися. Перевіряй опис конкретного турніру.', + }, + { + question: 'Чи можна брати участь самому (без команди)?', + answer: 'Так. У багатьох турнірах можна брати участь індивідуально або знайти команду вже на платформі.', + }, + { + question: 'Чи можна брати участь у кількох турнірах одночасно?', + answer: 'Так, якщо графік не перетинається і ти встигаєш брати повноцінну участь у всіх обраних змаганнях.', + }, + ]; + + const toggleFaq = (index: number) => { + setOpenIndex(openIndex === index ? null : index); + }; + + return ( +
        +
        +
        +
        + + + Допомога та відповіді + +

        + ЧАСТІ ПИТАННЯ +

        +

        + Зібрали для вас відповіді на найпопулярніші запитання. Не знайшли свого? Напишіть нам у підтримку! +

        +
        + +
        +
        + {faqs.map((faq, index) => { + const isOpen = openIndex === index; + + return ( +
        + +
        +
        +
        + {faq.answer} +
        +
        +
        +
        + ); + })} +
        +
        +
        + ); +}; + +export { FaqPage }; \ No newline at end of file From 6468aa683e35bef7daadeec05f1ba8e46a4cec26 Mon Sep 17 00:00:00 2001 From: izachoc Date: Thu, 26 Mar 2026 09:55:28 +0200 Subject: [PATCH 123/369] feat: Fixed footer (check App.tsx&components/MainLayout.tsx) and added tournaments page --- frontend/src/App.tsx | 54 ++--- frontend/src/components/MainLayout.tsx | 15 ++ frontend/src/components/TournamentCard.tsx | 195 ++++++++++------ frontend/src/data/mockTournaments.ts | 118 ++++++++++ .../Home/components/TournamentSlider.tsx | 111 +-------- frontend/src/pages/Profile/Profile.tsx | 102 +++++---- .../pages/TournamentsPage/TournamentsPage.tsx | 211 +++++++++++++++++- 7 files changed, 566 insertions(+), 240 deletions(-) create mode 100644 frontend/src/components/MainLayout.tsx create mode 100644 frontend/src/data/mockTournaments.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b91216c..332adf8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { MainLayout } from "./components/MainLayout"; import { Home } from "./pages/Home/Home"; import { Profile } from "./pages/Profile/Profile"; import { TournamentsPage } from "./pages/TournamentsPage/TournamentsPage"; @@ -11,45 +12,46 @@ import { FaqPage } from "./pages/FaqPage/FaqPage"; import SignIn from "./pages/Auth/SignIn"; import SignUp from "./pages/Auth/SignUp"; import SignOut from "./pages/Auth/SignOut"; -import { Header } from "./components/Header"; -import { Footer } from "./components/Footer"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; -import {Toaster} from 'react-hot-toast' +import { Toaster } from 'react-hot-toast'; export const App = () => { return ( <> - - -
        -
        + + - } /> - - - - } /> - } /> - + {/* Сторінки з Хедером та Футером */} + }> + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> -
        -
        - + ); -}; +}; \ No newline at end of file diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx new file mode 100644 index 0000000..2f9763c --- /dev/null +++ b/frontend/src/components/MainLayout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from "react-router-dom"; +import { Header } from "./Header"; +import { Footer } from "./Footer"; + +export const MainLayout = () => { + return ( +
        +
        +
        + +
        +
        +
        + ); +}; diff --git a/frontend/src/components/TournamentCard.tsx b/frontend/src/components/TournamentCard.tsx index 0c8c5ad..3dce75f 100644 --- a/frontend/src/components/TournamentCard.tsx +++ b/frontend/src/components/TournamentCard.tsx @@ -1,90 +1,159 @@ import React from "react"; +import { type Tournament } from "../data/mockTournaments"; -interface Tag { - label: string; - type: "accent" | "light" | "pink" | "custom"; - customStyle?: React.CSSProperties; -} - -interface TournamentCardProps { - title: string; - description: string; - tags: Tag[]; - buttonText: string; - buttonClass: string; - cardStyle?: React.CSSProperties; - buttonStyle?: React.CSSProperties; -} +const STATUS_CFG = { + registration: { + label: "Реєстрація", + badgeBg: "bg-[#dcfce7]", + badgeText: "text-[#15803d]", + dot: "bg-[#22c55e]", + gradFrom: "#34d399", + gradTo: "#059669", + icon: , + btnText: "Подати заявку", + btnClass: "bg-primary text-white hover:bg-indigo-600", + }, + active: { + label: "В процесі", + badgeBg: "bg-[#fdf2f8]", + badgeText: "text-[#be185d]", + dot: "bg-pink-400", + gradFrom: "#c084fc", + gradTo: "#ec4899", + icon: , + btnText: "Спостерігати", + btnClass: + "bg-transparent border-2 border-primary text-primary hover:bg-indigo-50", + }, + completed: { + label: "Завершено", + badgeBg: "bg-slate-100", + badgeText: "text-slate-500", + dot: "bg-slate-400", + gradFrom: "#94a3b8", + gradTo: "#475569", + icon: ( + + ), + btnText: "Переглянути результати", + btnClass: "bg-bg-body text-slate-400 cursor-default", + }, +}; -const tagColors = { - accent: "bg-accent text-slate-900", +const TAG_COLORS = { + accent: "bg-[#fef3c7] text-[#92400e]", + primary: "bg-indigo-50 text-primary", + pink: "bg-pink-50 text-pink-700", light: "bg-slate-100 text-slate-500", - pink: "bg-pink-100 text-pink-700", - custom: "", }; export const TournamentCard = ({ + status, title, - description, tags, - buttonText, - buttonClass, - cardStyle, - buttonStyle, -}: TournamentCardProps) => { - const isDefaultWhiteCard = !cardStyle?.backgroundColor; - const isOutline = buttonClass.includes("btn-outline"); + desc, + teams, + max, + deadline, +}: Tournament) => { + const cfg = STATUS_CFG[status]; + const pct = Math.min(100, Math.round((teams / max) * 100)); + const isFull = teams >= max; + const gradId = `grad-${title.replace(/\s+/g, "-")}`; return ( -
        -
        -
        +
        +
        +
        +
        + + + + + + + + + + + {cfg.icon} + +
        +

        + {title} +

        +
        + +
        {tags.map((tag, i) => ( {tag.label} ))}
        -

        - {title} -

        - -

        - {description} +

        + {desc}

        -
        -
        +
        +
        + + Команди + + + {teams}{" "} + / {max} + +
        +
        +
        +
        +
        + +
        +
        + + + + До {deadline} +
        + + + {cfg.label} + +
        +
        diff --git a/frontend/src/data/mockTournaments.ts b/frontend/src/data/mockTournaments.ts new file mode 100644 index 0000000..5d5887f --- /dev/null +++ b/frontend/src/data/mockTournaments.ts @@ -0,0 +1,118 @@ +export type TournamentStatus = "registration" | "active" | "completed"; +export type TagType = "accent" | "light" | "pink" | "primary"; + +export interface Tag { + label: string; + type: TagType; +} + +export interface Tournament { + id: number; + title: string; + status: TournamentStatus; + tags: Tag[]; + desc: string; + deadline: string; + max: number; + teams: number; +} + +const RAW_DATA: Omit[] = [ + { + title: "Турнір бравл старс", + desc: "Бла-бла-бла, леон бравл старс, мільйон гемів і т.д. БАГААААААААТО ТЕКСТУУУУУУУУУУУУУУУ", + status: "active", + deadline: "20 Квітня", + max: 30, + teams: 15, + tags: [ + { label: "Битва", type: "accent" }, + { label: "Екшин", type: "pink" }, + ], + }, + { + title: "24 години челендж", + desc: "Любите поїсти? Ми пропонуємо турнір, протягом 24 годин ви повинні з'їсти 100 пачок картоплі фрі.", + status: "registration", + deadline: "25 Квітня", + max: 10, + teams: 2, + tags: [ + { label: "Їжа", type: "primary" }, + { label: "Макдональдс", type: "light" }, + ], + }, + { + title: "Бокс у костюмах динозаврів", + desc: "Звичайні бойові мистецтва це надто серйозно. А от вийти на ринг, будучи двометровим надувним тиранозавром весело, вхвххв", + status: "registration", + deadline: "1 Травня", + max: 8, + teams: 5, + tags: [ + { label: "Спорт", type: "primary" }, + { label: "T-Rex", type: "accent" }, + ], + }, + { + title: "Забіг синіх їжаків", + desc: "Одягаємо колючі сині перуки, червоні кросівки і біжимо на впередки, збираючи розкидані металеві кільця. Головне правило - не врізатися в дерева на надзвуковій швидкості, на страховку грошей нема.", + status: "active", + deadline: "10 Травня", + max: 50, + teams: 45, + tags: [ + { label: "Біг", type: "accent" }, + { label: "Розваги", type: "light" }, + ], + }, + { + title: "Екстремальний продаж", + desc: "Ви на міському ярмарку. Ваша мета - продати багато плетених вушиків за дві години людям, які прийшли 'просто подивитися'. Дозволено використовувати будь-які методи переконання.", + status: "completed", + deadline: "15 Травня", + max: 20, + teams: 20, + tags: [ + { label: "Бізнес", type: "primary" }, + { label: "Нерви", type: "pink" }, + ], + }, + { + title: "Лабораторія геніальних сестер", + desc: "Вас замикають у кімнаті з купою незрозумілих хімікатів, дивними винаходами та собакою, що розмовляє. Завдання: зробити зілля перетворення на халка і втекти", + status: "registration", + deadline: "22 Травня", + max: 5, + teams: 3, + tags: [ + { label: "Хімія", type: "pink" }, + { label: "Веселощі", type: "accent" }, + ], + }, + { + title: "Ідей більше нема", + desc: "Ех, мені лінь далі думати, просто текст на відчепись, аби було гарно і красиво, усьо", + status: "completed", + deadline: "30 Травня", + max: 1, + teams: 1, + tags: [ + { label: "Ідеї", type: "light" }, + { label: "Думи", type: "light" }, + ], + }, +]; + +export const TOURNAMENTS_DATA: Tournament[] = Array.from( + { length: 35 }, + (_, index) => { + const baseCard = RAW_DATA[index % RAW_DATA.length]; + + return { + ...baseCard, + id: index + 1, + title: `${baseCard.title} #${index + 1}`, + }; + }, +); diff --git a/frontend/src/pages/Home/components/TournamentSlider.tsx b/frontend/src/pages/Home/components/TournamentSlider.tsx index f27e5ff..43570f7 100644 --- a/frontend/src/pages/Home/components/TournamentSlider.tsx +++ b/frontend/src/pages/Home/components/TournamentSlider.tsx @@ -1,111 +1,6 @@ import { useEffect, useRef, useCallback } from "react"; import { TournamentCard } from "../../../components/TournamentCard"; - -const CARDS_DATA = [ - { - title: "Турнір бравл старс", - description: - "Бла-бла-бла, леон бравл старс, мільйон гемів і т.д. БАГААААААААТО ТЕКСТУУУУУУУУУУУУУУУ", - buttonText: "Показати скіл", - buttonClass: "btn-primary", - tags: [ - { label: "Битва", type: "accent" as const }, - { label: "Екшин", type: "pink" as const }, - ], - }, - { - title: "24 години челендж", - description: - "Любите поїсти? Ми пропонуємо турнір, протягом 24 годин ви повинні з'їсти 100 пачок картоплі фрі.", - buttonText: "ням-ням", - buttonClass: "btn-outline", - buttonStyle: { - color: "var(--color-primary)", - borderColor: "var(--color-primary)", - }, - tags: [ - { - label: "Їжа", - type: "custom" as const, - customStyle: { background: "var(--color-primary)", color: "white" }, - }, - { label: "Макдональдс", type: "light" as const }, - ], - }, - { - title: "Бокс у костюмах динозаврів", - description: - "Звичайні бойові мистецтва це надто серйозно. А от вийти на ринг, будучи двометровим надувним тиранозавром весело, вхвххв", - buttonText: "Ррррр!", - buttonClass: "btn-accent", - cardStyle: { backgroundColor: "#14532d", color: "white" }, - tags: [ - { - label: "Спорт", - type: "custom" as const, - customStyle: { background: "rgba(255, 255, 255, 0.2)", color: "white" }, - }, - { label: "T-Rex", type: "accent" as const }, - ], - }, - { - title: "Забіг синіх їжаків", - description: - "Одягаємо колючі сині перуки, червоні кросівки і біжимо на впередки, збираючи розкидані металеві кільця. Головне правило - не врізатися в дерева на надзвуковій швидкості, на страховку грошей нема.", - buttonText: "ГААААЗУЙ", - buttonClass: "btn-primary", - cardStyle: { - backgroundColor: "#1e3a8a", - color: "white", - border: "2px solid #fbbf24", - }, - tags: [ - { label: "Біг", type: "accent" as const }, - { label: "Розваги", type: "light" as const }, - ], - }, - { - title: "Екстремальний продаж", - description: - "Ви на міському ярмарку. Ваша мета - продати багато плетених вушиків за дві години людям, які прийшли 'просто подивитися'. Дозволено використовувати будь-які методи переконання.", - buttonText: "Підписатись", - buttonClass: "btn-outline", - buttonStyle: { - color: "#d97706", - borderColor: "#d97706", - }, - tags: [ - { - label: "Бізнес", - type: "custom" as const, - customStyle: { background: "#d97706", color: "white" }, - }, - { label: "Нерви", type: "pink" as const }, - ], - }, - { - title: "Лабораторія геніальних сестер", - description: - "Вас замикають у кімнаті з купою незрозумілих хімікатів, дивними винаходами та собакою, що розмовляє. Завдання: зробити зілля перетворення на халка і втекти", - buttonText: "В путь", - buttonClass: "btn-primary", - tags: [ - { label: "Хімія", type: "pink" as const }, - { label: "Веселощі", type: "accent" as const }, - ], - }, - { - title: "Ідей більше нема", - description: - "Ех, мені лінь далі думати, просто текст на відчепись, аби було гарно і красиво, усьо", - buttonText: "Реєстрація", - buttonClass: "btn-primary", - tags: [ - { label: "Ідеї", type: "light" as const }, - { label: "Думи", type: "light" as const }, - ], - }, -]; +import { TOURNAMENTS_DATA } from "../../../data/mockTournaments"; export const TournamentSlider = () => { const sliderRef = useRef(null); @@ -215,9 +110,9 @@ export const TournamentSlider = () => { ref={sliderRef} >
        - {CARDS_DATA.map((card, index) => ( + {TOURNAMENTS_DATA.map((card) => (
        diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index ff82d47..3afcea7 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,15 +1,15 @@ -import './Profile.css'; -import { useSelector } from 'react-redux'; -import { auth } from '../../firebase'; -import { store, type RootState } from '../../store'; -import { deleteUser } from '@/api/requests'; -import { useMutation } from '@tanstack/react-query'; -import { setUser } from '@/slices/user'; +import "./Profile.css"; +import { useSelector } from "react-redux"; +import { auth } from "../../firebase"; +import { store, type RootState } from "../../store"; +import { deleteUser } from "@/api/requests"; +import { useMutation } from "@tanstack/react-query"; +import { setUser } from "@/slices/user"; const Profile = () => { const user = useSelector((s: RootState) => s.user); const deleteUserMutation = useMutation({ - mutationKey: ['delete user'], + mutationKey: ["delete user"], mutationFn: async () => { if (!auth.currentUser) return; await deleteUser(auth.currentUser); @@ -19,23 +19,30 @@ const Profile = () => { store.dispatch(setUser(null)); }, onError: (e) => { - console.log('An error occured while trying to delete account', e.message) - } - }) + console.log("An error occured while trying to delete account", e.message); + }, + }); const handleDeleteUser = async () => { if (!auth.currentUser) return; deleteUserMutation.mutate(); }; - if (!user) return
        Loading...
        + if (!user) return
        Loading...
        ; return (
        - {/* Головна картка профілю */}
        - + @@ -46,7 +53,9 @@ const Profile = () => {
        - +
        @@ -74,10 +83,7 @@ const Profile = () => {
        - {/* Нижня сітка з двома колонками */}
        - - {/* Картка Турніри */}

        Турніри @@ -88,11 +94,19 @@ const Profile = () => { "Напишіть свою мову програмування на рівні C++", "Напишіть гру на JavaScript", "Напишіть чат-бота на Python", - "Напишіть свою операційну систему" + "Напишіть свою операційну систему", ].map((item, index) => (
        {item} - +
        @@ -100,38 +114,42 @@ const Profile = () => {

        - {/* Картка Команди */}

        Команди

        - {[ - "Шалені програмісти", - "Кодери мрії", - "Лінус Торвальдс" - ].map((item, index) => ( -
        -
        - - - - - - - - - + {["Шалені програмісти", "Кодери мрії", "Лінус Торвальдс"].map( + (item, index) => ( +
        +
        + + + + + + + + + +
        + {item}
        - {item} -
        - ))} + ), + )}
        -
        ); }; -export { Profile }; \ No newline at end of file +export { Profile }; diff --git a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx index 36a3e59..234e423 100644 --- a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx +++ b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx @@ -1,3 +1,212 @@ +import { useState, useMemo } from "react"; +import { Hero } from "../../components/Hero"; +import { TournamentCard } from "../../components/TournamentCard"; +import { + TOURNAMENTS_DATA, + type TournamentStatus, +} from "../../data/mockTournaments"; + +const PER_PAGE = 15; + export const TournamentsPage = () => { - return

        Сторінка для всіх турнірів

        ; + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const [page, setPage] = useState(1); + + const filteredData = useMemo(() => { + return TOURNAMENTS_DATA.filter((item) => { + const matchesSearch = item.title + .toLowerCase() + .includes(query.toLowerCase()); + const matchesFilter = filter === "all" || item.status === filter; + return matchesSearch && matchesFilter; + }); + }, [query, filter]); + + const totalPages = Math.ceil(filteredData.length / PER_PAGE); + const currentData = useMemo(() => { + const start = (page - 1) * PER_PAGE; + return filteredData.slice(start, start + PER_PAGE); + }, [filteredData, page]); + + const handleFilterChange = (newFilter: TournamentStatus | "all") => { + setFilter(newFilter); + setPage(1); + }; + + const handleSearch = (e: React.ChangeEvent) => { + setQuery(e.target.value); + setPage(1); + }; + + return ( +
        + + +
        +
        +
        +
        + + + + +
        + +
        + + + + +
        +
        +
        + +
        + Знайдено:{" "} + {filteredData.length}{" "} + турнірів +
        + + {currentData.length > 0 ? ( +
        + {currentData.map((tournament) => ( + + ))} +
        + ) : ( +
        + 🔍 +

        + Нічого не знайдено +

        +

        + Спробуй змінити фільтр або запит +

        +
        + )} + + {totalPages > 1 && ( +
        +
        + + +
        + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (p) => ( + + ), + )} +
        + + +
        +
        + Сторінка {page} з{" "} + {totalPages} +
        +
        + )} +
        +
        + ); }; From b3bd638cfe2628066fd34e854105ec807139c24c Mon Sep 17 00:00:00 2001 From: izachoc Date: Thu, 2 Apr 2026 21:41:17 +0300 Subject: [PATCH 124/369] refactor: replace old auth with single AuthPage, perfect TournamentCard layout --- .env.example | 10 - frontend/package-lock.json | 1627 ++++------------- frontend/package.json | 11 +- frontend/public/star.json | 1 + frontend/src/App.tsx | 12 +- frontend/src/components/Header.tsx | 2 +- frontend/src/components/TournamentCard.tsx | 110 +- frontend/src/components/ui/Button.tsx | 67 + frontend/src/components/ui/index.ts | 2 + frontend/src/pages/Auth/AuthPage.tsx | 389 ++++ frontend/src/pages/Auth/SignUp.tsx | 143 +- frontend/src/pages/Home/Home.tsx | 2 +- .../pages/TournamentPage/TournamentPage.tsx | 9 +- .../pages/TournamentsPage/TournamentsPage.tsx | 173 +- package-lock.json | 6 + 15 files changed, 1035 insertions(+), 1529 deletions(-) delete mode 100644 .env.example create mode 100644 frontend/public/star.json create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/pages/Auth/AuthPage.tsx create mode 100644 package-lock.json diff --git a/.env.example b/.env.example deleted file mode 100644 index f42210e..0000000 --- a/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -SECRET_KEY="your secret key" -SQLALCHEMY_DATABASE_URI="your database url" -VITE_BACKEND_URL= -VITE_FIREBASE_API_KEY= -VITE_FIREBASE_AUTH_DOMAIN= -VITE_FIREBASE_PROJECT_ID= -VITE_FIREBASE_STORAGE_BUCKET= -VITE_FIREBASE_MESSAGING_SENDER_ID= -VITE_FIREBASE_APP_ID= -VITE_FIREBASE_MEASUREMENT_ID= \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b757c6c..9ddd959 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,20 +9,25 @@ "version": "0.0.0", "dependencies": { "@firebase-oss/ui-react": "^7.0.2-beta", + "@hookform/resolvers": "^5.2.2", "@lottiefiles/react-lottie-player": "^3.6.0", "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", - "axios": "^1.13.6", + "axios": "1.13.6", "firebase": "^12.10.0", + "framer-motion": "^12.38.0", "lottie-react": "^2.4.1", + "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -558,601 +563,201 @@ "node": ">=20.19.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { + "node_modules/@esbuild/linux-x64": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ - "arm" + "x64" ], "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@firebase-oss/ui-core": { - "version": "7.0.2-beta", - "resolved": "https://registry.npmjs.org/@firebase-oss/ui-core/-/ui-core-7.0.2-beta.tgz", - "integrity": "sha512-pm6u75QU/0BaDC7Z9qbRw/hHrUXUMXLzOgtvWHeH4+EtuJRMiC6JI+g/O2Pmenz4o5rKhSZfC3d58+6j0g5lOA==", + "node_modules/@firebase-oss/ui-core": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-core/-/ui-core-7.0.2-beta.tgz", + "integrity": "sha512-pm6u75QU/0BaDC7Z9qbRw/hHrUXUMXLzOgtvWHeH4+EtuJRMiC6JI+g/O2Pmenz4o5rKhSZfC3d58+6j0g5lOA==", "license": "MIT", "dependencies": { "@firebase-oss/ui-translations": "7.0.2-beta", @@ -1856,6 +1461,18 @@ "node": ">=6" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2045,307 +1662,86 @@ "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.57.1", @@ -2373,84 +1769,6 @@ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2466,151 +1784,39 @@ "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", - "cpu": [ - "arm64" - ], + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "node_modules/@tailwindcss/oxide": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", - "cpu": [ - "arm64" - ], + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { @@ -2645,67 +1851,6 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, "node_modules/@tailwindcss/vite": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", @@ -4519,18 +3664,31 @@ "node": ">= 6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, "node_modules/function-bind": { @@ -5031,146 +4189,6 @@ "lightningcss-win32-x64-msvc": "1.31.1" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", - "cpu": [ - "arm64" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", - "cpu": [ - "arm64" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", - "cpu": [ - "x64" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", - "cpu": [ - "x64" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", - "cpu": [ - "arm" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", - "cpu": [ - "arm64" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", - "cpu": [ - "arm64" - ], - "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.31.1", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", @@ -5211,46 +4229,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", - "cpu": [ - "arm64" - ], - "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.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5315,6 +4293,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -5395,6 +4382,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5716,6 +4718,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.72.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", + "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -6724,7 +5742,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index 512402b..4dfc656 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,20 +14,25 @@ }, "dependencies": { "@firebase-oss/ui-react": "^7.0.2-beta", + "@hookform/resolvers": "^5.2.2", "@lottiefiles/react-lottie-player": "^3.6.0", "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", - "axios": "^1.13.6", + "axios": "1.13.6", "firebase": "^12.10.0", + "framer-motion": "^12.38.0", "lottie-react": "^2.4.1", + "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -49,4 +54,4 @@ "vite": "^7.3.1", "vitest": "^4.1.2" } -} +} \ No newline at end of file diff --git a/frontend/public/star.json b/frontend/public/star.json new file mode 100644 index 0000000..796845e --- /dev/null +++ b/frontend/public/star.json @@ -0,0 +1 @@ +{"tgs":1,"v":"5.5.2","fr":60,"ip":0,"op":180,"w":512,"h":512,"nm":"STAR_05","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 18","parent":18,"sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.352,"y":0},"t":0,"s":[2.5,55,0],"to":[0.376,0,0],"ti":[-1.25,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.352,"y":0},"t":10,"s":[1,55,0],"to":[0.376,0,0],"ti":[1.456,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[5.023,55,0],"to":[-2.083,0,0],"ti":[-0.839,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[-5.04,55,0],"to":[0.839,0,0],"ti":[0.006,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[10.059,55,0],"to":[-0.006,0,0],"ti":[0.813,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":41,"s":[-5.076,55,0],"to":[-0.813,0,0],"ti":[0.003,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":49,"s":[5.178,55,0],"to":[-0.003,0,0],"ti":[0.446,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":59,"s":[-5.096,55,0],"to":[-0.446,0,0],"ti":[-1.266,0,0]},{"i":{"x":0.57,"y":0.57},"o":{"x":0.167,"y":0.167},"t":69,"s":[2.5,55,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[2.5,55,0],"to":[-0.25,0,0],"ti":[-1.25,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.352,"y":0},"t":100,"s":[1,55,0],"to":[0.376,0,0],"ti":[1.456,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":110,"s":[5.023,55,0],"to":[-2.083,0,0],"ti":[-0.839,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":117,"s":[-5.04,55,0],"to":[0.839,0,0],"ti":[0.006,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":124,"s":[10.059,55,0],"to":[-0.006,0,0],"ti":[0.813,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":131,"s":[-5.076,55,0],"to":[-0.813,0,0],"ti":[0.003,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":139,"s":[5.178,55,0],"to":[-0.003,0,0],"ti":[0.446,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":149,"s":[-5.096,55,0],"to":[-0.446,0,0],"ti":[-1.266,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0},"t":159,"s":[2.5,55,0],"to":[0,0,0],"ti":[-1.25,0,0]},{"t":179,"s":[2.5,55,0]}]},"a":{"a":0,"k":[50,50,0]}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 3","parent":19,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.42],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.42],"y":[0]},"t":10,"s":[-2]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[9]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[11]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[9]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":59,"s":[5]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":69,"s":[38]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.42],"y":[0]},"t":78,"s":[17]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":90,"s":[24]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":100,"s":[20]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":110,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":117,"s":[15]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":124,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":131,"s":[12]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":139,"s":[18]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":149,"s":[7]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.42],"y":[0]},"t":159,"s":[12]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.42],"y":[0]},"t":168,"s":[-2]},{"t":179,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":0,"s":[56.75,137.649,0],"to":[0.25,-8.5,0],"ti":[-0.167,-5.833,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":60,"s":[58.25,86.649,0],"to":[0.167,5.833,0],"ti":[0,-10.417,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":69,"s":[57.75,172.649,0],"to":[0,10.417,0],"ti":[-0.083,3.5,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":78,"s":[58.25,149.149,0],"to":[0.083,-3.5,0],"ti":[0,-0.417,0]},{"i":{"x":0.56,"y":0.56},"o":{"x":0.206,"y":0.206},"t":87,"s":[58.25,151.649,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":90,"s":[58.25,151.649,0],"to":[0,-10.833,0],"ti":[0.25,0.667,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":150,"s":[58.25,86.649,0],"to":[-0.25,-0.667,0],"ti":[0.25,-8,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":159,"s":[56.75,147.649,0],"to":[-0.25,8,0],"ti":[0,1.667,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":168,"s":[56.75,134.649,0],"to":[0,-1.667,0],"ti":[0,-0.5,0]},{"t":179,"s":[56.75,137.649,0]}]},"a":{"a":0,"k":[52.25,151,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-7.128,23.854],[-3.548,1.183],[-3.898,-8.835],[0,0]],"o":[[0,0],[2.469,-8.264],[3.93,-1.31],[9.49,21.511],[0,0]],"v":[[28,151],[38.003,84.449],[47,69],[58.97,82.292],[76.5,143.5]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 2","parent":19,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.41],"y":[0]},"t":0,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.41],"y":[0]},"t":10,"s":[-21.667]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[-16.667]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[-28.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[-9.263]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[-15.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[-0.824]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":59,"s":[-8]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":72,"s":[8]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":91,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":110,"s":[5]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":117,"s":[-6]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":124,"s":[1]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":131,"s":[-13]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":139,"s":[-3]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":149,"s":[-13]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.41],"y":[0]},"t":160,"s":[-42]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.41],"y":[0]},"t":170,"s":[-23]},{"t":179,"s":[-27]}]},"p":{"a":1,"k":[{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":0,"s":[-49.5,147.149,0],"to":[0,-10.833,0],"ti":[0,-1.667,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":60,"s":[-49.5,82.149,0],"to":[0,1.667,0],"ti":[0,-10.417,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":69,"s":[-49.5,157.149,0],"to":[0,10.417,0],"ti":[0,1.667,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":78,"s":[-49.5,144.649,0],"to":[0,-1.667,0],"ti":[0,-0.417,0]},{"i":{"x":0.56,"y":0.56},"o":{"x":0.206,"y":0.206},"t":87,"s":[-49.5,147.149,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":90,"s":[-49.5,147.149,0],"to":[0,-10.833,0],"ti":[0,-1.667,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":150,"s":[-49.5,82.149,0],"to":[0,1.667,0],"ti":[0,-10.417,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":159,"s":[-49.5,157.149,0],"to":[0,10.417,0],"ti":[0,1.667,0]},{"i":{"x":0.56,"y":1},"o":{"x":0.44,"y":0},"t":168,"s":[-49.5,144.649,0],"to":[0,-1.667,0],"ti":[0,-0.417,0]},{"i":{"x":0.56,"y":0.56},"o":{"x":0.206,"y":0.206},"t":177,"s":[-49.5,147.149,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[-49.5,147.149,0]}]},"a":{"a":0,"k":[-64.5,146.5,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-9.12,21.812],[-3.542,0.236],[-2.753,-8.671],[0,0]],"o":[[0,0],[3.251,-7.776],[4.075,-0.272],[7.381,23.248],[0,0]],"v":[[-88,141.5],[-71.804,78.873],[-61.5,65.5],[-51.298,79.753],[-41,146.5]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 19","parent":6,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.41,"y":0},"t":0,"s":[54.503,80.92,0],"to":[0,-1.994,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.41,"y":0},"t":60,"s":[54.503,68.958,0],"to":[0,0,0],"ti":[0,-1.994,0]},{"i":{"x":0.57,"y":0.57},"o":{"x":0.41,"y":0.41},"t":70,"s":[54.503,80.92,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.417,"y":0},"t":90,"s":[54.503,80.92,0],"to":[0,-0.925,0],"ti":[0,0,0]},{"i":{"x":0.577,"y":1},"o":{"x":0.417,"y":0},"t":120,"s":[54.503,80.92,0],"to":[0,-0.925,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.41,"y":0},"t":150,"s":[54.503,68.958,0],"to":[0,0,0],"ti":[0,-1.994,0]},{"t":160,"s":[54.503,80.92,0]}]},"a":{"a":0,"k":[-68.81,51.101,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.013,-15.497],[0.5,14]],"o":[[-3.5,18],[-0.412,-11.536]],"v":[[-103,49.5],[-34.5,51]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.090196078431,0.321568627451,0.298039215686,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.530532418045,0.647307631549,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 18","parent":7,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.41,"y":0},"t":0,"s":[53.503,81.92,0],"to":[0,-1.994,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.41,"y":0},"t":60,"s":[53.503,69.958,0],"to":[0,0,0],"ti":[0,-1.994,0]},{"i":{"x":0.57,"y":0.57},"o":{"x":0.41,"y":0.41},"t":70,"s":[53.503,81.92,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.417,"y":0},"t":90,"s":[53.503,81.92,0],"to":[0,-0.925,0],"ti":[0,0,0]},{"i":{"x":0.577,"y":1},"o":{"x":0.417,"y":0},"t":120,"s":[53.503,81.92,0],"to":[0,-0.925,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.41,"y":0},"t":150,"s":[53.503,69.958,0],"to":[0,0,0],"ti":[0,-1.994,0]},{"t":160,"s":[53.503,81.92,0]}]},"a":{"a":0,"k":[-68.81,51.101,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.013,-15.497],[0.5,14]],"o":[[-3.5,18],[-0.412,-11.536]],"v":[[-103,49.5],[-34.5,51]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.090196078431,0.321568627451,0.298039215686,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.530532418045,0.647307631549,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":3,"nm":"Null 13","parent":1,"sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[122.687,17.18,0],"to":[0,-5.829,0],"ti":[0,-1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[122.687,-17.793,0],"to":[0,1.667,0],"ti":[0,-4.995,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[122.687,27.18,0],"to":[0,4.995,0],"ti":[0,1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":79,"s":[122.687,12.18,0],"to":[0,-1.667,0],"ti":[0,4.995,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[122.687,17.18,0],"to":[0,-4.995,0],"ti":[0,-1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[122.687,-17.793,0],"to":[0,1.667,0],"ti":[0,-4.995,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[122.687,27.18,0],"to":[0,4.995,0],"ti":[0,1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":169,"s":[122.687,12.18,0],"to":[0,-1.667,0],"ti":[0,-0.833,0]},{"t":179,"s":[122.687,17.18,0]}]},"a":{"a":0,"k":[50,50,0]},"s":{"a":1,"k":[{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":60,"s":[106,88,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":69,"s":[100,100,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":89,"s":[100,100,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":150,"s":[106,88,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":159,"s":[100,100,100]},{"t":179,"s":[100,100,100]}]}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":3,"nm":"Null 12","parent":1,"sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[-25.313,16.18,0],"to":[0,-5.829,0],"ti":[0,-1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[-25.313,-18.793,0],"to":[0,1.667,0],"ti":[0,-4.995,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[-25.313,26.18,0],"to":[0,4.995,0],"ti":[0,1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":79,"s":[-25.313,11.18,0],"to":[0,-1.667,0],"ti":[0,4.995,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[-25.313,16.18,0],"to":[0,-4.995,0],"ti":[0,-1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[-25.313,-18.793,0],"to":[0,1.667,0],"ti":[0,-4.995,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[-25.313,26.18,0],"to":[0,4.995,0],"ti":[0,1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":169,"s":[-25.313,11.18,0],"to":[0,-1.667,0],"ti":[0,-0.833,0]},{"t":179,"s":[-25.313,16.18,0]}]},"a":{"a":0,"k":[50,50,0]},"s":{"a":1,"k":[{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":60,"s":[106,88,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":69,"s":[100,100,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":89,"s":[100,100,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":150,"s":[106,88,100]},{"i":{"x":[0.58,0.58,0.58],"y":[1,1,1]},"o":{"x":[0.43,0.43,0.43],"y":[0,0,0]},"t":159,"s":[100,100,100]},{"t":179,"s":[100,100,100]}]}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 16","parent":7,"sr":1,"ks":{"p":{"a":0,"k":[48.063,25.445,0]},"a":{"a":0,"k":[68.25,-3.375,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":96,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[56.898,-16.424],[58.961,-2.926]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[49.248,1.503],[60.539,9.436]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[49.248,1.503],[60.539,9.436]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":110,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[48.162,-2.338],[58.748,-4.594]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":118,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[49.248,1.503],[60.539,9.436]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[49.248,1.503],[60.539,9.436]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":96,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[44.635,-10.33],[48.43,3.998]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.886,9.442],[48.829,12.863]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.886,9.442],[48.829,12.863]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":110,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[36.04,8.039],[48.376,2.801]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":118,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.886,9.442],[48.829,12.863]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.886,9.442],[48.829,12.863]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":96,"s":[{"i":[[0,0],[0,0],[-23.749,0.889],[0,0]],"o":[[0,0],[0,0],[24.387,-0.914],[0,0]],"v":[[33.619,4.128],[40.313,15.653],[73.194,-5.924],[107.245,14.103]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0],[-21.526,0.73],[0,0]],"o":[[0,0],[0,0],[21.365,-0.724],[0,0]],"v":[[25.839,23.692],[39.897,21.538],[72.514,9.214],[107.235,17.657]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0],[-21.526,0.73],[0,0]],"o":[[0,0],[0,0],[21.365,-0.724],[0,0]],"v":[[25.839,23.692],[39.897,21.538],[72.514,9.214],[107.235,17.657]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":110,"s":[{"i":[[0,0],[0,0],[-24.049,0.911],[0,0]],"o":[[0,0],[0,0],[24.795,-0.94],[0,0]],"v":[[28.065,22.437],[40.369,14.86],[73.285,-7.966],[107.247,13.623]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":118,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0],[-21.526,0.73],[0,0]],"o":[[0,0],[0,0],[21.365,-0.724],[0,0]],"v":[[25.839,23.692],[39.897,21.538],[72.514,9.214],[107.235,17.657]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0],[-21.526,0.73],[0,0]],"o":[[0,0],[0,0],[21.365,-0.724],[0,0]],"v":[[25.839,23.692],[39.897,21.538],[72.514,9.214],[107.235,17.657]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":90,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[39.163,13.953],[72.004,-12.951],[107.099,11.372]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[38.76,21.45],[71.768,9.233],[109.548,16.409]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[38.76,21.45],[71.768,9.233],[109.548,16.409]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":114,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[39.163,13.953],[72.004,-12.951],[107.099,11.372]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[39.163,13.953],[72.004,-12.951],[107.099,11.372]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[38.76,21.45],[71.768,9.233],[109.548,16.409]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[38.76,21.45],[71.768,9.233],[109.548,16.409]],"c":true}]},{"t":140,"s":[{"i":[[-4.319,19.919],[8.178,-16.798],[0,0],[-22.266,-0.237],[0,0]],"o":[[-24.74,-35.941],[5.882,3.803],[0,0],[22.266,0.237],[0,0]],"v":[[123.419,-15.966],[17.969,-6.092],[39.163,13.953],[72.004,-12.951],[107.099,11.372]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 20","parent":7,"sr":1,"ks":{"p":{"a":0,"k":[54.7,66.07,0]},"a":{"a":0,"k":[-68.113,37.25,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":60,"s":[{"i":[[0,0],[-1.374,-1.898],[0,0],[2.132,8.247]],"o":[[0,0],[33.789,-25.524],[0,0],[-2.417,-9.349]],"v":[[-102,17.25],[-100.433,29.474],[-37.299,28.278],[-35,12.75]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0},"t":69,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":90,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[-2.955,-4.091],[0,0],[0.855,4.922]],"o":[[0,0],[27.448,-23.315],[0,0],[-1.652,-9.505]],"v":[[-102,17.25],[-101.038,20.236],[-35.295,18.057],[-35,12.75]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[-2.955,-4.091],[0,0],[0.855,4.922]],"o":[[0,0],[27.448,-23.315],[0,0],[-1.652,-9.505]],"v":[[-102,17.25],[-101.038,20.236],[-35.295,18.057],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":114,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":120,"s":[{"i":[[0,0],[-3.152,-4.364],[0,0],[3.428,8.961]],"o":[[0,0],[18.352,-8.916],[0,0],[-3.443,-9.018]],"v":[[-102,17.25],[-96.706,36.844],[-40.807,36.781],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[-2.955,-4.091],[0,0],[0.855,4.922]],"o":[[0,0],[27.448,-23.315],[0,0],[-1.652,-9.505]],"v":[[-102,17.25],[-101.038,20.236],[-35.295,18.057],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[-2.955,-4.091],[0,0],[0.855,4.922]],"o":[[0,0],[27.448,-23.315],[0,0],[-1.652,-9.505]],"v":[[-102,17.25],[-101.038,20.236],[-35.295,18.057],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":140,"s":[{"i":[[0,0],[-1.638,-2.264],[0,0],[2.324,8.353]],"o":[[0,0],[31.497,-23.058],[0,0],[-2.569,-9.3]],"v":[[-102,17.25],[-99.879,30.569],[-37.82,29.541],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":150,"s":[{"i":[[0,0],[-1.374,-1.898],[0,0],[2.132,8.247]],"o":[[0,0],[33.789,-25.524],[0,0],[-2.417,-9.349]],"v":[[-102,17.25],[-100.433,29.474],[-37.299,28.278],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0},"t":159,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.980392216701,0.749019607843,0.290196078431,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":4},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-39,-8.5]],"v":[[41.75,38.25],[55.5,55],[92.25,55],[105.25,38]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-36.759,-31.505]],"v":[[40.362,33.166],[55.5,55],[92.25,55],[108.026,32.019]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-39,-8.5]],"v":[[41.75,38.25],[55.5,55],[92.25,55],[105.25,38]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-39,-8.5]],"v":[[41.75,38.25],[55.5,55],[92.25,55],[105.25,38]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0.83,11.642]],"o":[[0,0],[0,0],[0,0],[-43.486,-24.082]],"v":[[40.144,23.081],[43.41,49.664],[110.408,52.561],[109.097,19.619]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0.83,11.642]],"o":[[0,0],[0,0],[0,0],[-43.486,-24.082]],"v":[[40.144,23.081],[43.41,49.664],[110.408,52.561],[109.097,19.619]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.44,"y":0},"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-39,-8.5]],"v":[[41.75,38.25],[55.5,55],[92.25,55],[105.25,38]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":120,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-38.883,-9.703]],"v":[[41.677,37.984],[55.5,55],[92.25,55],[105.395,37.687]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0],[0,0],[0.83,11.642]],"o":[[0,0],[0,0],[0,0],[-43.486,-24.082]],"v":[[40.144,23.081],[43.41,49.664],[110.408,52.561],[109.097,19.619]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0.83,11.642]],"o":[[0,0],[0,0],[0,0],[-43.486,-24.082]],"v":[[40.144,23.081],[43.41,49.664],[110.408,52.561],[109.097,19.619]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-37.074,-28.267]],"v":[[40.557,33.881],[55.5,55],[92.25,55],[107.635,32.86]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-36.759,-31.505]],"v":[[40.362,33.166],[55.5,55],[92.25,55],[108.026,32.019]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0},"t":159,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-39,-8.5]],"v":[[41.75,38.25],[55.5,55],[92.25,55],[105.25,38]],"c":true}]},{"t":179,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-39,-8.5]],"v":[[41.75,38.25],[55.5,55],[92.25,55],[105.25,38]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[-143,-0.75]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Shape Layer 15","parent":7,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":0,"s":[44.462,37.403,0],"to":[0.756,2.786,0],"ti":[-2.728,-2.463,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[49,54.12,0],"to":[2.728,2.463,0],"ti":[-1.978,1.315,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[60.832,52.182,0],"to":[1.978,-1.315,0],"ti":[2.728,2.463,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.45,"y":0},"t":130,"s":[60.867,46.227,0],"to":[-2.728,-2.463,0],"ti":[2.734,1.471,0]},{"t":143,"s":[44.462,37.403,0]}]},"a":{"a":0,"k":[-84.813,28.801,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[12.25,-6.75],[-11.25,9.5]],"o":[[-13.62,7.505],[8.24,-6.958]],"v":[[-77,12],[-64.25,28.25]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[5.5,-7],[-4.168,5.28]],"o":[[-5.5,7],[3.75,-4.75]],"v":[[-104,23],[-96.25,29.75]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[12.066,7.023],[-4.964,-9.692],[-9.219,4.491]],"o":[[-15.838,-9.219],[3.45,6.736],[6.853,-3.339]],"v":[[-81.816,20.469],[-104.036,38.198],[-82.998,45.29]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.407283079858,0.757814654182,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[2.75,0.5]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.865,-16.924],[-7.092,28.582],[10.332,3.335]],"o":[[-7.25,31.75],[3.717,-14.98],[-12.601,-4.067]],"v":[[-108.75,25],[-61,35],[-76.41,5.722]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.01568627451,0.325490196078,0.898039275525,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Shape Layer 14","parent":7,"sr":1,"ks":{"p":{"a":0,"k":[54.249,47.267,0]},"a":{"a":0,"k":[74.436,18.447,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[14,-17.25],[-10.519,-6.972],[-6.25,5.75],[13.25,15.5]],"o":[[-12.031,14.824],[21.5,14.25],[6.25,-5.75],[-14.49,-16.951]],"v":[[48,-1],[51.75,42.5],[98.25,41.25],[101.25,0.75]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Shape Layer 8","parent":6,"sr":1,"ks":{"p":{"a":0,"k":[48.063,25.445,0]},"a":{"a":0,"k":[68.25,-3.375,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":96,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[53.462,-17.974],[57.052,-3.799]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[44.559,-3.939],[55.523,2.205]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.705,-1.273],[55.523,2.205]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":109,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[46.329,-10.316],[57.635,-6.085]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":114,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[44.559,-3.939],[55.523,2.205]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.705,-1.273],[55.523,2.205]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[51.5,-19.75],[58.25,-8.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":96,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[41.719,-9.391],[47.985,3.749]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.196,5.093],[47.647,8.538]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.524,6.665],[47.647,8.538]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":109,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.615,-0.725],[48.114,1.926]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":114,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.196,5.093],[47.647,8.538]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[34.524,6.665],[47.647,8.538]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[37.5,-9.25],[48.25,0]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":96,"s":[{"i":[[0,0],[0,0],[-24.709,1.584],[0,0]],"o":[[0,0],[0,0],[24.322,-1.514],[0,0]],"v":[[33.45,2.178],[40.659,14.124],[73.229,-6.736],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0],[-24.656,2.379],[0,0]],"o":[[0,0],[0,0],[22.497,-2.171],[0,0]],"v":[[27.954,15.853],[40.862,15.561],[72.882,0.945],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0],[-24.656,2.379],[0,0]],"o":[[0,0],[0,0],[22.497,-2.171],[0,0]],"v":[[29.226,16.598],[40.862,15.561],[72.882,0.945],[107.25,12.5]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":109,"s":[{"i":[[0,0],[0,0],[-24.73,1.281],[0,0]],"o":[[0,0],[0,0],[25.016,-1.264],[0,0]],"v":[[28.173,11.853],[40.582,13.578],[73.361,-9.661],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":114,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0],[-24.656,2.379],[0,0]],"o":[[0,0],[0,0],[22.497,-2.171],[0,0]],"v":[[27.954,15.853],[40.862,15.561],[72.882,0.945],[107.25,12.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0],[-24.656,2.379],[0,0]],"o":[[0,0],[0,0],[22.497,-2.171],[0,0]],"v":[[29.226,16.598],[40.862,15.561],[72.882,0.945],[107.25,12.5]],"c":false}]},{"t":140,"s":[{"i":[[0,0],[0,0],[-24.752,0.961],[0,0]],"o":[[0,0],[0,0],[25.75,-1],[0,0]],"v":[[29.25,6.25],[40.5,13],[73.5,-12.75],[107.25,12.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":90,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.863,-13.085],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.491,0.598],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.491,0.598],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":114,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.863,-13.085],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":120,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.863,-13.085],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.491,0.598],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.491,0.598],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]},{"t":140,"s":[{"i":[[-21.275,-0.521],[0,0],[-11.673,9.66],[0,0],[0,0]],"o":[[28.762,0.705],[0,0],[-71.151,-69.387],[0,0],[0,0]],"v":[[73.863,-13.085],[106.719,11.565],[125.145,1.924],[22.084,1.205],[40.73,12.578]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Shape Layer 21","parent":6,"sr":1,"ks":{"p":{"a":0,"k":[54.2,66.32,0]},"a":{"a":0,"k":[-68.113,37.25,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":60,"s":[{"i":[[0,0],[-1.694,-4.779],[0,0],[0.886,4.768]],"o":[[0,0],[22.133,-26.611],[0,0],[-1.765,-9.494]],"v":[[-102,17.25],[-98.582,27.979],[-35.217,24.988],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":69,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":90,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[-3.006,-4.544],[0,0],[0.983,2.663]],"o":[[0,0],[30.456,-31.683],[0,0],[-3.227,-9.078]],"v":[[-105.387,13.291],[-101.614,17.208],[-35.342,14.723],[-33.111,10.852]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[-3.006,-4.544],[0,0],[0.983,2.663]],"o":[[0,0],[30.456,-31.683],[0,0],[-3.227,-9.078]],"v":[[-105.387,13.291],[-101.614,17.208],[-35.342,14.723],[-33.111,10.852]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":114,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":120,"s":[{"i":[[0,0],[-3.165,-4.515],[0,0],[3.358,8.77]],"o":[[0,0],[17.752,-9.011],[0,0],[-3.406,-9.027]],"v":[[-102,17.25],[-96.613,36.746],[-40.686,36.584],[-35,12.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[-3.006,-4.544],[0,0],[0.983,2.663]],"o":[[0,0],[30.456,-31.683],[0,0],[-3.227,-9.078]],"v":[[-105.387,13.291],[-101.614,17.208],[-35.342,14.723],[-33.111,10.852]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[-3.006,-4.544],[0,0],[0.983,2.663]],"o":[[0,0],[30.456,-31.683],[0,0],[-3.227,-9.078]],"v":[[-105.387,13.291],[-101.614,17.208],[-35.342,14.723],[-33.111,10.852]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":140,"s":[{"i":[[0,0],[-1.913,-4.74],[0,0],[1.253,5.362]],"o":[[0,0],[21.483,-23.997],[0,0],[-2.008,-9.425]],"v":[[-102,17.25],[-98.29,29.281],[-36.029,26.71],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":150,"s":[{"i":[[0,0],[-1.694,-4.779],[0,0],[0.886,4.768]],"o":[[0,0],[22.133,-26.611],[0,0],[-1.765,-9.494]],"v":[[-102,17.25],[-98.582,27.979],[-35.217,24.988],[-35,12.75]],"c":false}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":159,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[-3.25,-4.5],[0,0],[3.5,9]],"o":[[0,0],[17.5,-8],[0,0],[-3.5,-9]],"v":[[-102,17.25],[-96.5,37.25],[-41,37.25],[-35,12.75]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.980392216701,0.749019607843,0.290196078431,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":4},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.25,-13]],"v":[[-101.75,39],[-89.75,55.25],[-53,55.25],[-37,39.25]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-41.762,-25.321]],"v":[[-99.437,27.336],[-89.75,55.25],[-53,55.25],[-33.761,26.988]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.25,-13]],"v":[[-101.75,39],[-89.75,55.25],[-53,55.25],[-37,39.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.25,-13]],"v":[[-101.75,39],[-89.75,55.25],[-53,55.25],[-37,39.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[3.699,10.773]],"o":[[0,0],[0,0],[0,0],[-38.301,-33.831]],"v":[[-104.203,18.485],[-100.75,52.181],[-36.355,52.112],[-33.723,16.562]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[3.699,10.773]],"o":[[0,0],[0,0],[0,0],[-38.301,-33.831]],"v":[[-104.203,18.485],[-100.75,52.181],[-36.355,52.112],[-33.723,16.562]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.43,"y":0},"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.25,-13]],"v":[[-101.75,39],[-89.75,55.25],[-53,55.25],[-37,39.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":120,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.658,-13.67]],"v":[[-101.624,38.366],[-89.75,55.25],[-53,55.25],[-36.824,38.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":129,"s":[{"i":[[0,0],[0,0],[0,0],[3.699,10.773]],"o":[[0,0],[0,0],[0,0],[-38.301,-33.831]],"v":[[-104.203,18.485],[-100.75,52.181],[-36.355,52.112],[-33.723,16.562]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[3.699,10.773]],"o":[[0,0],[0,0],[0,0],[-38.301,-33.831]],"v":[[-104.203,18.485],[-100.75,52.181],[-36.355,52.112],[-33.723,16.562]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-40.707,-23.591]],"v":[[-99.761,28.974],[-89.75,55.25],[-53,55.25],[-34.216,28.71]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-41.762,-25.321]],"v":[[-99.437,27.336],[-89.75,55.25],[-53,55.25],[-33.761,26.988]],"c":true}]},{"i":{"x":0.57,"y":1},"o":{"x":0.4,"y":0},"t":159,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.25,-13]],"v":[[-101.75,39],[-89.75,55.25],[-53,55.25],[-37,39.25]],"c":true}]},{"t":179,"s":[{"i":[[0,0],[0,0],[0,0],[-6,7.25]],"o":[[0,0],[0,0],[0,0],[-34.25,-13]],"v":[[-101.75,39],[-89.75,55.25],[-53,55.25],[-37,39.25]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Shape Layer 13","parent":6,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":0,"s":[44.462,37.403,0],"to":[0.756,2.786,0],"ti":[-2.728,-2.463,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":100,"s":[49,54.12,0],"to":[2.728,2.463,0],"ti":[-1.978,1.315,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.167,"y":0.167},"t":102,"s":[60.832,52.182,0],"to":[1.978,-1.315,0],"ti":[2.728,2.463,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.45,"y":0},"t":130,"s":[60.867,46.227,0],"to":[-2.728,-2.463,0],"ti":[2.734,1.471,0]},{"t":143,"s":[44.462,37.403,0]}]},"a":{"a":0,"k":[-84.813,28.801,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[12.25,-6.75],[-11.25,9.5]],"o":[[-13.62,7.505],[8.24,-6.958]],"v":[[-77,12],[-64.25,28.25]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[5.5,-7],[-4.168,5.28]],"o":[[-5.5,7],[3.75,-4.75]],"v":[[-104,23],[-96.25,29.75]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[12.066,7.023],[-4.964,-9.692],[-9.219,4.491]],"o":[[-15.838,-9.219],[3.45,6.736],[6.853,-3.339]],"v":[[-81.816,20.469],[-104.036,38.198],[-82.998,45.29]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.407283079858,0.757814654182,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[2.75,0.5]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.865,-16.924],[-7.092,28.582],[10.332,3.335]],"o":[[-7.25,31.75],[3.717,-14.98],[-12.601,-4.067]],"v":[[-108.75,25],[-61,35],[-76.41,5.722]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.01568627451,0.325490196078,0.898039275525,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"Shape Layer 10","parent":6,"sr":1,"ks":{"p":{"a":0,"k":[54.249,47.267,0]},"a":{"a":0,"k":[74.436,18.447,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[14,-17.25],[-10.519,-6.972],[-6.25,5.75],[13.25,15.5]],"o":[[-12.031,14.824],[21.5,14.25],[6.25,-5.75],[-14.49,-16.951]],"v":[[48,-1],[51.75,42.5],[98.25,41.25],[101.25,0.75]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"Shape Layer 4","parent":1,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[50.25,29.254,0],"to":[0,-7.083,0],"ti":[0,-1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[50.25,-13.246,0],"to":[0,1.667,0],"ti":[0,-6.25,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[50.25,39.254,0],"to":[0,6.25,0],"ti":[0,1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":79,"s":[50.25,24.254,0],"to":[0,-1.667,0],"ti":[0,6.25,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[50.25,29.254,0],"to":[0,-6.25,0],"ti":[0,-1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[50.25,-13.246,0],"to":[0,1.667,0],"ti":[0,-6.25,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[50.25,39.254,0],"to":[0,6.25,0],"ti":[0,1.667,0]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":169,"s":[50.25,24.254,0],"to":[0,-1.667,0],"ti":[0,-0.833,0]},{"t":179,"s":[50.25,29.254,0]}]},"a":{"a":0,"k":[-2.25,33.254,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[{"i":[[0,0],[-18,0.5],[0,0]],"o":[[0,0],[18,-0.5],[0,0]],"v":[[-22.5,23.5],[-1.5,44],[18,22.5]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[{"i":[[0,0],[-30.488,-0.871],[0,0]],"o":[[0,0],[35,1],[0,0]],"v":[[-20,22.5],[-1.5,40],[18,21.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[{"i":[[0,0],[-18,0.5],[0,0]],"o":[[0,0],[18,-0.5],[0,0]],"v":[[-22.5,23.5],[-1.5,44],[18,22.5]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[0,0],[-18,0.5],[0,0]],"o":[[0,0],[18,-0.5],[0,0]],"v":[[-22.5,23.5],[-1.5,44],[18,22.5]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[{"i":[[0,0],[-30.488,-0.871],[0,0]],"o":[[0,0],[35,1],[0,0]],"v":[[-20,22.5],[-1.5,40],[18,21.5]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[{"i":[[0,0],[-18,0.5],[0,0]],"o":[[0,0],[18,-0.5],[0,0]],"v":[[-22.5,23.5],[-1.5,44],[18,22.5]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[-18,0.5],[0,0]],"o":[[0,0],[18,-0.5],[0,0]],"v":[[-22.5,23.5],[-1.5,44],[18,22.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.003921568627,0.003921568627,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"Shape Layer 22","parent":16,"sr":1,"ks":{"p":{"a":0,"k":[-4.057,5.683,0]},"a":{"a":0,"k":[0.366,0.002,0]},"s":{"a":0,"k":[104.167,104.167,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[19.5,-0.5],[-18.496,0.627]],"o":[[-25.996,0.667],[29.5,-1]],"v":[[0,-12],[-2,12]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.090196078431,0.321568627451,0.298039215686,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.956862804936,0.815686334348,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0.5,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 6","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"STAR","parent":19,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.41],"y":[0]},"t":0,"s":[-9]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":70,"s":[10]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":80,"s":[7]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":90,"s":[8]},{"i":{"x":[0.57],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":150,"s":[0]},{"t":160,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":0,"s":[2.84,165.57,0],"to":[0.909,-4.283,0],"ti":[-1.653,-1.65,0]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":60,"s":[8.291,139.874,0],"to":[1.653,1.65,0],"ti":[-0.744,-3.432,0]},{"i":{"x":0.6,"y":1},"o":{"x":0.41,"y":0},"t":69,"s":[12.755,175.473,0],"to":[0.744,3.432,0],"ti":[-0.001,1.677,0]},{"i":{"x":0.6,"y":1},"o":{"x":0.41,"y":0},"t":79,"s":[12.756,160.467,0],"to":[0.001,-1.677,0],"ti":[0.827,3.432,0]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[12.763,165.408,0],"to":[-0.827,-3.432,0],"ti":[0.829,-1.578,0]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":150,"s":[7.791,139.874,0],"to":[-0.829,1.578,0],"ti":[1.65,-3.565,0]},{"i":{"x":0.6,"y":1},"o":{"x":0.41,"y":0},"t":159,"s":[7.791,174.874,0],"to":[-1.65,3.565,0],"ti":[0.825,1.551,0]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":169,"s":[-2.111,161.266,0],"to":[-0.825,-1.551,0],"ti":[-0.825,-0.717,0]},{"t":179,"s":[2.84,165.57,0]}]},"a":{"a":0,"k":[7.791,164.874,0]},"s":{"a":1,"k":[{"i":{"x":[0.59,0.59,0.59],"y":[1,1,1]},"o":{"x":[0.41,0.41,0.41],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.59,0.59,0.59],"y":[1,1,1]},"o":{"x":[0.41,0.41,0.41],"y":[0,0,0]},"t":60,"s":[101.95,94.993,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":69,"s":[100,100,100]},{"i":{"x":[0.59,0.59,0.59],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":89,"s":[100,100,100]},{"i":{"x":[0.59,0.59,0.59],"y":[1,1,1]},"o":{"x":[0.41,0.41,0.41],"y":[0,0,0]},"t":150,"s":[101.95,94.993,100]},{"i":{"x":[0.59,0.59,0.59],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":159,"s":[100,100,100]},{"t":179,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":0,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[196,-12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":5,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[108.5,-52.5],[204,-62.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[0,0],[-29.671,-31.602]],"o":[[0,0],[19.876,11.353]],"v":[[108.5,-45.844],[204.171,-28.898]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":10,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[108.5,-42.5],[200.5,-4.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":15,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[108.5,-55.5],[202,-66]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[-26.007,-40.607]],"o":[[0,0],[19.876,11.353]],"v":[[107.169,-48.512],[207.507,-29.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":20,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[105.007,-53.601],[196,-12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[0,0],[-33.99,-11.354]],"o":[[0,0],[21.699,7.287]],"v":[[106.338,-51.183],[201.324,-45.778]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":24,"s":[{"i":[[0,0],[-36.02,-7.069]],"o":[[0,0],[22.941,4.518]],"v":[[108.789,-53.524],[203.129,-57.055]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":25,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[108.5,-63.702],[204,-62.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[-29.671,-31.602]],"o":[[0,0],[19.876,11.353]],"v":[[107.013,-61.185],[204.171,-28.898]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":30,"s":[{"i":[[0,0],[-33.057,-35.032]],"o":[[0,0],[16.303,17.277]],"v":[[106.519,-57.372],[200.5,-4.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":33,"s":[{"i":[[0,0],[-35.681,-15.044]],"o":[[0,0],[21.12,7.895]],"v":[[104.377,-69.003],[201.498,-45.432]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":35,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[105.537,-68.93],[202,-66]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[-26.007,-40.607]],"o":[[0,0],[19.876,11.353]],"v":[[106.183,-73.405],[207.507,-29.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":40,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[105.022,-63.197],[196,-12.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":43,"s":[{"i":[[0,0],[-31.448,-18.873]],"o":[[0,0],[21.699,7.287]],"v":[[103.401,-71.728],[201.324,-45.778]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":45,"s":[{"i":[[0,0],[-38.175,-10.491]],"o":[[0,0],[22.905,6.295]],"v":[[105.058,-68.181],[204,-62.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[-30.14,-40.279]],"o":[[0,0],[19.876,11.353]],"v":[[102.605,-67.854],[204.171,-28.898]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":50,"s":[{"i":[[0,0],[-28.026,-31.252]],"o":[[0,0],[15.859,17.685]],"v":[[103.59,-60.866],[200.5,-4.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":53,"s":[{"i":[[0,0],[-38.442,-21.901]],"o":[[0,0],[20.971,8.032]],"v":[[102.442,-68.857],[201.498,-45.432]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":55,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[105.066,-66.019],[202,-66]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[-26.007,-40.607]],"o":[[0,0],[19.876,11.353]],"v":[[102.755,-66.932],[207.507,-29.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":60,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[196,-12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":64,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[202.437,-25.321]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[202.437,-25.321]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":73,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[105.5,-41],[199.5,6.5]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":83,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[107,-46.5],[197.5,-22]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[196,-12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":95,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[108.5,-52.5],[204,-62.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[0,0],[-29.671,-31.602]],"o":[[0,0],[19.876,11.353]],"v":[[108.5,-45.844],[204.171,-28.898]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":100,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[108.5,-42.5],[200.5,-4.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":105,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[108.5,-55.5],[202,-66]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":108,"s":[{"i":[[0,0],[-26.007,-40.607]],"o":[[0,0],[19.876,11.353]],"v":[[107.169,-48.512],[207.507,-29.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":110,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[105.007,-53.601],[196,-12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":113,"s":[{"i":[[0,0],[-33.99,-11.354]],"o":[[0,0],[21.699,7.287]],"v":[[108.323,-52.128],[201.324,-45.778]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[0,0],[-36.02,-7.069]],"o":[[0,0],[22.941,4.518]],"v":[[109.928,-53.266],[203.129,-57.055]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":115,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[108.5,-63.702],[204,-62.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":118,"s":[{"i":[[0,0],[-29.671,-31.602]],"o":[[0,0],[19.876,11.353]],"v":[[107.013,-61.185],[204.171,-28.898]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":120,"s":[{"i":[[0,0],[-33.057,-35.032]],"o":[[0,0],[16.303,17.277]],"v":[[106.519,-57.372],[200.5,-4.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":123,"s":[{"i":[[0,0],[-35.681,-15.044]],"o":[[0,0],[21.12,7.895]],"v":[[104.377,-69.003],[201.498,-45.432]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":125,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[105.537,-68.93],[202,-66]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":128,"s":[{"i":[[0,0],[-26.007,-40.607]],"o":[[0,0],[19.876,11.353]],"v":[[106.183,-73.405],[207.507,-29.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":130,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[105.022,-63.197],[196,-12.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":133,"s":[{"i":[[0,0],[-31.448,-18.873]],"o":[[0,0],[21.699,7.287]],"v":[[103.401,-71.728],[201.324,-45.778]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":135,"s":[{"i":[[0,0],[-38.175,-10.491]],"o":[[0,0],[22.905,6.295]],"v":[[105.058,-68.181],[204,-62.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":138,"s":[{"i":[[0,0],[-30.14,-40.279]],"o":[[0,0],[19.876,11.353]],"v":[[102.605,-67.854],[204.171,-28.898]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":140,"s":[{"i":[[0,0],[-28.026,-31.252]],"o":[[0,0],[15.859,17.685]],"v":[[103.59,-60.866],[200.5,-4.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":143,"s":[{"i":[[0,0],[-38.442,-21.901]],"o":[[0,0],[20.971,8.032]],"v":[[102.442,-68.857],[201.498,-45.432]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":145,"s":[{"i":[[0,0],[-37,-5]],"o":[[0,0],[23.54,3.181]],"v":[[105.066,-66.019],[202,-66]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":148,"s":[{"i":[[0,0],[-26.007,-40.607]],"o":[[0,0],[19.876,11.353]],"v":[[102.755,-66.932],[207.507,-29.393]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":150,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[196,-12.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":154,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[202.437,-25.321]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.167,"y":0.167},"t":157,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[202.437,-25.321]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":163,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[105.5,-41],[199.5,6.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":173,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[107,-46.5],[197.5,-22]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[-28,-24]],"o":[[0,0],[18.035,15.459]],"v":[[106.5,-45],[196,-12.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":0,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":5,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[26.436,-181.909],[69.472,-100.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":10,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[26.936,-172.409],[71.472,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":15,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[14.436,-176.409],[64.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":20,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":25,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[26.436,-181.909],[69.472,-100.221]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":30,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[24.46,-163.69],[71.472,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":33,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.8,-159.279],[67.146,-94.721]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":35,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[10.485,-161.43],[64.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[14.149,-160.145],[66.969,-94.388]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":40,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.002,-157.931],[67.972,-94.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":43,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.486,-159.283],[68.971,-98.215]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":45,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[21.027,-159.955],[69.472,-100.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[18.574,-153.571],[70.803,-96.561]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":50,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[24.972,-150.894],[71.472,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":53,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.978,-152.634],[67.146,-94.721]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":55,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[15.908,-156.422],[64.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.96,-158.363],[66.969,-94.388]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":60,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":73,"s":[{"i":[[0,0],[-10.972,-43.779]],"o":[[0,0],[7.117,28.396]],"v":[[22.436,-166.409],[71.472,-87.221]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":83,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-174.909],[68.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":95,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[26.436,-181.909],[69.472,-100.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":100,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[26.936,-172.409],[71.472,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":105,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[14.436,-176.409],[64.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":110,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":115,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[26.436,-181.909],[69.472,-100.221]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":120,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[24.46,-163.69],[71.472,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":123,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.8,-159.279],[67.146,-94.721]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":125,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[10.485,-161.43],[64.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":128,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[14.149,-160.145],[66.969,-94.388]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":130,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.002,-157.931],[67.972,-94.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":133,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.486,-159.283],[68.971,-98.215]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":135,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[21.027,-159.955],[69.472,-100.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":138,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[18.574,-153.571],[70.803,-96.561]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":140,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[24.972,-150.894],[71.472,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":143,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.978,-152.634],[67.146,-94.721]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":145,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[15.908,-156.422],[64.972,-94.721]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":148,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[16.96,-158.363],[66.969,-94.388]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":150,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":163,"s":[{"i":[[0,0],[-10.972,-43.779]],"o":[[0,0],[7.117,28.396]],"v":[[22.436,-166.409],[71.472,-87.221]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":173,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-174.909],[68.972,-94.721]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[-14.604,-40.456]],"o":[[0,0],[9.94,27.535]],"v":[[20.436,-170.409],[67.972,-94.221]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":0,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":3,"s":[{"i":[[17.338,25.654],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-6.652,46.602],[0,0],[21.915,11.598],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-213.348,-22.602],[-131.669,62.174],[-142.326,182.508],[-66.5,173],[-118.162,163.334],[-109.84,50.344]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":4,"s":[{"i":[[16.436,28.248],[0,0],[-18.591,-10.635],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.456,51.485],[0,0],[22.056,12.648],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-207.044,-47.485],[-131.218,60.708],[-143.792,180.817],[-68.5,173],[-119.064,163.109],[-108.599,48.089]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":5,"s":[{"i":[[16,29.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[2,37.5],[0,0],[22.124,13.155],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-204,-59.5],[-131,60],[-144.5,180],[-68.5,173],[-119.5,163],[-108,47]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[18.662,21.846],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[10.985,34.838],[0,0],[21.709,10.057],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.314,-15.573],[-133.995,66.656],[-140.174,184.992],[-68.008,172.171],[-116.838,163.666],[-111.993,54.654]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":10,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-221,6.5],[-135.5,70],[-138,187.5],[-66.5,172],[-115.5,164],[-114,58.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":13,"s":[{"i":[[11.181,38.599],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.819,62.099],[0,0],[21.915,11.598],[0,0],[-18,-4],[-34.174,-12.829]],"v":[[-215.681,-27.599],[-131.839,62.346],[-140.662,184.505],[-67.5,173],[-116.831,165.664],[-112.003,50.513]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":15,"s":[{"i":[[21,27.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[3,32],[0,0],[22.124,13.155],[0,0],[-18,-4],[-32,-14]],"v":[[-208.5,-54.5],[-130,58.5],[-142,183],[-67.5,172.5],[-117.5,166.5],[-111,46.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[20.334,21.177],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7.657,37.225],[0,0],[21.709,10.057],[0,0],[-18,-4],[-36.326,-11.671]],"v":[[-214.157,-19.225],[-131.997,63.824],[-139.338,185.995],[-67.5,172.5],[-116.169,164.836],[-112.664,53.488]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":20,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[17.338,25.654],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-6.652,46.602],[0,0],[21.915,11.598],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-213.348,-22.602],[-131.669,62.174],[-142.326,182.508],[-66.5,173],[-118.162,163.334],[-109.84,50.344]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":24,"s":[{"i":[[16.436,28.248],[0,0],[-18.591,-10.635],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.456,51.485],[0,0],[22.056,12.648],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-207.044,-47.485],[-131.218,60.708],[-143.792,180.817],[-68.5,173],[-119.064,163.109],[-108.599,48.089]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":25,"s":[{"i":[[16,29.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[2,37.5],[0,0],[22.124,13.155],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-204,-59.5],[-131,60],[-144.5,180],[-68.5,173],[-119.5,163],[-108,47]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[18.662,21.846],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[10.985,34.838],[0,0],[21.709,10.057],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.314,-15.573],[-133.995,66.656],[-140.174,184.992],[-68.008,172.171],[-116.838,163.666],[-111.993,54.654]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":30,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-221,6.5],[-135.5,70],[-138,187.5],[-66.5,172],[-115.5,164],[-114,58.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":33,"s":[{"i":[[11.181,38.599],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.819,62.099],[0,0],[21.915,11.598],[0,0],[-18,-4],[-34.174,-12.829]],"v":[[-215.681,-27.599],[-131.839,62.346],[-140.662,184.505],[-67.5,173],[-116.831,165.664],[-112.003,50.513]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":35,"s":[{"i":[[21,27.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[3,32],[0,0],[22.124,13.155],[0,0],[-18,-4],[-32,-14]],"v":[[-208.5,-54.5],[-130,58.5],[-142,183],[-67.5,172.5],[-117.5,166.5],[-111,46.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[20.334,21.177],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7.657,37.225],[0,0],[21.709,10.057],[0,0],[-18,-4],[-36.326,-11.671]],"v":[[-214.157,-19.225],[-131.997,63.824],[-139.338,185.995],[-67.5,172.5],[-116.169,164.836],[-112.664,53.488]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":40,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":43,"s":[{"i":[[17.338,25.654],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-6.652,46.602],[0,0],[21.915,11.598],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-213.348,-22.602],[-131.669,62.174],[-142.326,182.508],[-66.5,173],[-118.162,163.334],[-109.84,50.344]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":45,"s":[{"i":[[16,29.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[2,37.5],[0,0],[22.124,13.155],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-204,-59.5],[-131,60],[-144.5,180],[-68.5,173],[-119.5,163],[-108,47]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[18.662,21.846],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[10.985,34.838],[0,0],[21.709,10.057],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.314,-15.573],[-133.995,66.656],[-140.174,184.992],[-68.008,172.171],[-116.838,163.666],[-111.993,54.654]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":50,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-221,6.5],[-135.5,70],[-138,187.5],[-66.5,172],[-115.5,164],[-114,58.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":53,"s":[{"i":[[11.181,38.599],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.819,62.099],[0,0],[21.915,11.598],[0,0],[-18,-4],[-34.174,-12.829]],"v":[[-215.681,-27.599],[-131.839,62.346],[-140.662,184.505],[-67.5,173],[-116.831,165.664],[-112.003,50.513]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":55,"s":[{"i":[[21,27.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[3,32],[0,0],[22.124,13.155],[0,0],[-18,-4],[-32,-14]],"v":[[-208.5,-54.5],[-130,58.5],[-142,183],[-67.5,172.5],[-117.5,166.5],[-111,46.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[20.334,21.177],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7.657,37.225],[0,0],[21.709,10.057],[0,0],[-18,-4],[-36.326,-11.671]],"v":[[-214.157,-19.225],[-131.997,63.824],[-139.338,185.995],[-67.5,172.5],[-116.169,164.836],[-112.664,53.488]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":60,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":64,"s":[{"i":[[22.473,24.449],[0,0],[-19.046,-8.351],[0,0],[19.512,7.16],[-16.655,43.388]],"o":[[14.055,37.783],[0,0],[21.215,9.302],[0,0],[-17.311,-6.352],[-38.5,-10.5]],"v":[[-211.058,-24.065],[-133,66.5],[-162.263,168.012],[-68.5,172.5],[-130.355,155.795],[-113.5,57]],"c":true}]},{"i":{"x":0.61,"y":1},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[22.473,24.449],[0,0],[-19.046,-8.351],[0,0],[19.512,7.16],[-16.655,43.388]],"o":[[14.055,37.783],[0,0],[21.215,9.302],[0,0],[-17.311,-6.352],[-38.5,-10.5]],"v":[[-211.058,-24.065],[-133,66.5],[-162.263,168.012],[-68.5,172.5],[-130.355,155.795],[-113.5,57]],"c":true}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":73,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[12,26],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-218,27.5],[-133,72],[-138,196.5],[-68.5,172.5],[-118,171.5],[-115.5,60.5]],"c":true}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":83,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7,33.24],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.5,-13.5],[-132.5,63],[-144.5,180.5],[-68.5,172.5],[-120,165],[-113,52.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":93,"s":[{"i":[[17.338,25.654],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-6.652,46.602],[0,0],[21.915,11.598],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-213.348,-22.602],[-131.669,62.174],[-142.326,182.508],[-66.5,173],[-118.162,163.334],[-109.84,50.344]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":94,"s":[{"i":[[16.436,28.248],[0,0],[-18.591,-10.635],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.456,51.485],[0,0],[22.056,12.648],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-207.044,-47.485],[-131.218,60.708],[-143.792,180.817],[-68.5,173],[-119.064,163.109],[-108.599,48.089]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":95,"s":[{"i":[[16,29.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[2,37.5],[0,0],[22.124,13.155],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-204,-59.5],[-131,60],[-144.5,180],[-68.5,173],[-119.5,163],[-108,47]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[18.662,21.846],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[10.985,34.838],[0,0],[21.709,10.057],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.314,-15.573],[-133.995,66.656],[-140.174,184.992],[-68.008,172.171],[-116.838,163.666],[-111.993,54.654]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":100,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-221,6.5],[-135.5,70],[-138,187.5],[-66.5,172],[-115.5,164],[-114,58.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[11.181,38.599],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.819,62.099],[0,0],[21.915,11.598],[0,0],[-18,-4],[-34.174,-12.829]],"v":[[-215.681,-27.599],[-131.839,62.346],[-140.662,184.505],[-67.5,173],[-116.831,165.664],[-112.003,50.513]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":105,"s":[{"i":[[21,27.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[3,32],[0,0],[22.124,13.155],[0,0],[-18,-4],[-32,-14]],"v":[[-208.5,-54.5],[-130,58.5],[-142,183],[-67.5,172.5],[-117.5,166.5],[-111,46.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":108,"s":[{"i":[[20.334,21.177],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7.657,37.225],[0,0],[21.709,10.057],[0,0],[-18,-4],[-36.326,-11.671]],"v":[[-214.157,-19.225],[-131.997,63.824],[-139.338,185.995],[-67.5,172.5],[-116.169,164.836],[-112.664,53.488]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":110,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":113,"s":[{"i":[[17.338,25.654],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-6.652,46.602],[0,0],[21.915,11.598],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-213.348,-22.602],[-131.669,62.174],[-142.326,182.508],[-66.5,173],[-118.162,163.334],[-109.84,50.344]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[16.436,28.248],[0,0],[-18.591,-10.635],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.456,51.485],[0,0],[22.056,12.648],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-207.044,-47.485],[-131.218,60.708],[-143.792,180.817],[-68.5,173],[-119.064,163.109],[-108.599,48.089]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":115,"s":[{"i":[[16,29.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[2,37.5],[0,0],[22.124,13.155],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-204,-59.5],[-131,60],[-144.5,180],[-68.5,173],[-119.5,163],[-108,47]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":118,"s":[{"i":[[18.662,21.846],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[10.985,34.838],[0,0],[21.709,10.057],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.314,-15.573],[-133.995,66.656],[-140.174,184.992],[-68.008,172.171],[-116.838,163.666],[-111.993,54.654]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":120,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-221,6.5],[-135.5,70],[-138,187.5],[-66.5,172],[-115.5,164],[-114,58.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":123,"s":[{"i":[[11.181,38.599],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.819,62.099],[0,0],[21.915,11.598],[0,0],[-18,-4],[-34.174,-12.829]],"v":[[-215.681,-27.599],[-131.839,62.346],[-140.662,184.505],[-67.5,173],[-116.831,165.664],[-112.003,50.513]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":125,"s":[{"i":[[21,27.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[3,32],[0,0],[22.124,13.155],[0,0],[-18,-4],[-32,-14]],"v":[[-208.5,-54.5],[-130,58.5],[-142,183],[-67.5,172.5],[-117.5,166.5],[-111,46.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":128,"s":[{"i":[[20.334,21.177],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7.657,37.225],[0,0],[21.709,10.057],[0,0],[-18,-4],[-36.326,-11.671]],"v":[[-214.157,-19.225],[-131.997,63.824],[-139.338,185.995],[-67.5,172.5],[-116.169,164.836],[-112.664,53.488]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":130,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":133,"s":[{"i":[[17.338,25.654],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-6.652,46.602],[0,0],[21.915,11.598],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-213.348,-22.602],[-131.669,62.174],[-142.326,182.508],[-66.5,173],[-118.162,163.334],[-109.84,50.344]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":135,"s":[{"i":[[16,29.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[2,37.5],[0,0],[22.124,13.155],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-204,-59.5],[-131,60],[-144.5,180],[-68.5,173],[-119.5,163],[-108,47]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":138,"s":[{"i":[[18.662,21.846],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[10.985,34.838],[0,0],[21.709,10.057],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.314,-15.573],[-133.995,66.656],[-140.174,184.992],[-68.008,172.171],[-116.838,163.666],[-111.993,54.654]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":140,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-221,6.5],[-135.5,70],[-138,187.5],[-66.5,172],[-115.5,164],[-114,58.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":143,"s":[{"i":[[11.181,38.599],[0,0],[-18.781,-9.878],[0,0],[31.85,7.078],[-10,42.5]],"o":[[-9.819,62.099],[0,0],[21.915,11.598],[0,0],[-18,-4],[-34.174,-12.829]],"v":[[-215.681,-27.599],[-131.839,62.346],[-140.662,184.505],[-67.5,173],[-116.831,165.664],[-112.003,50.513]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":145,"s":[{"i":[[21,27.5],[0,0],[-18.5,-11],[0,0],[31.85,7.078],[-10,42.5]],"o":[[3,32],[0,0],[22.124,13.155],[0,0],[-18,-4],[-32,-14]],"v":[[-208.5,-54.5],[-130,58.5],[-142,183],[-67.5,172.5],[-117.5,166.5],[-111,46.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":148,"s":[{"i":[[20.334,21.177],[0,0],[-19.059,-8.768],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7.657,37.225],[0,0],[21.709,10.057],[0,0],[-18,-4],[-36.326,-11.671]],"v":[[-214.157,-19.225],[-131.997,63.824],[-139.338,185.995],[-67.5,172.5],[-116.169,164.836],[-112.664,53.488]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":150,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":154,"s":[{"i":[[22.473,24.449],[0,0],[-19.046,-8.351],[0,0],[19.512,7.16],[-16.655,43.388]],"o":[[14.055,37.783],[0,0],[21.215,9.302],[0,0],[-17.311,-6.352],[-38.5,-10.5]],"v":[[-211.058,-24.065],[-133,66.5],[-162.263,168.012],[-68.5,172.5],[-130.355,155.795],[-113.5,57]],"c":true}]},{"i":{"x":0.61,"y":1},"o":{"x":0.167,"y":0.167},"t":157,"s":[{"i":[[22.473,24.449],[0,0],[-19.046,-8.351],[0,0],[19.512,7.16],[-16.655,43.388]],"o":[[14.055,37.783],[0,0],[21.215,9.302],[0,0],[-17.311,-6.352],[-38.5,-10.5]],"v":[[-211.058,-24.065],[-133,66.5],[-162.263,168.012],[-68.5,172.5],[-130.355,155.795],[-113.5,57]],"c":true}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":163,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[12,26],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-218,27.5],[-133,72],[-138,196.5],[-68.5,172.5],[-118,171.5],[-115.5,60.5]],"c":true}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":173,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[7,33.24],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-215.5,-13.5],[-132.5,63],[-144.5,180.5],[-68.5,172.5],[-120,165],[-113,52.5]],"c":true}]},{"t":179,"s":[{"i":[[20,18],[0,0],[-19.34,-7.646],[0,0],[31.85,7.078],[-10,42.5]],"o":[[15.5,33.5],[0,0],[21.5,8.5],[0,0],[-18,-4],[-38.5,-10.5]],"v":[[-217,-1.5],[-133,66.5],[-138,187.5],[-68.5,172.5],[-115.5,164],[-113.5,57]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784373564,0.694117647059,0.152941176471,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":0,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":3,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-20.316,69.435],[0,0],[-49.158,6.145],[0,0],[-12.1,-30.041],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[20.184,-52.565],[0,0],[40,-5],[0,0],[25.833,76.256],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-147.993,183.507],[-137.002,64.174],[-217.184,-43.935],[-78.171,-73.328],[14.823,-198.161],[98.169,-67.493],[226.167,-45.756],[146.828,73.512],[169.826,175.008],[81.334,174.836]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":4,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-21.684,66.85],[0,0],[-49.158,6.145],[0,0],[-15.522,-25.819],[0,0],[10.436,-16.401],[0,0]],"o":[[0,0],[-17,-14],[0,0],[16.363,-33.241],[0,0],[40,-5],[0,0],[25.946,46.431],[0,0],[-12.974,20.266],[0,0]],"v":[[-67,178],[-149.347,182.153],[-136.663,62.708],[-210.316,-61.85],[-77.381,-74.455],[16.965,-199.401],[97.718,-68.847],[226.054,-61.431],[147.956,71.143],[171.292,173.317],[81.109,174.272]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":5,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-150,181.5],[-136.5,62],[-207,-70.5],[-77,-75],[18,-200],[97.5,-69.5],[226,-69],[148.5,70],[172,172.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-10.685,32.799],[0,0],[-49.352,4.05],[0,0],[-8.833,-66.253],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.313,-66.092],[0,0],[35.341,-3.003],[0,0],[5.931,29.697],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-146.007,185.493],[-138.829,68.988],[-219.313,-25.908],[-79.329,-71.672],[17.002,-195.341],[99.497,-65.174],[226.333,-20.747],[145.172,81.314],[167.674,177.492],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":10,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.45,2.997],[0,0],[1,-35],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[33,-2],[0,0],[-0.887,31.052],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-140,72.5],[-225.5,-3.5],[-80.5,-70],[16.5,-193],[100.5,-63],[226.5,3.5],[143.5,87],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":13,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-26.152,78.596],[0,0],[-49.255,5.092],[0,0],[-11.096,-27.533],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[12.338,-28.456],[0,0],[37.659,-3.997],[0,0],[20.664,87.082],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-145.997,186.169],[-137.005,64.181],[-216.848,-46.096],[-78.503,-72.329],[5.851,-195.995],[98.503,-67.326],[224.836,-47.082],[148.824,73.356],[172.156,173.677],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":15,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-147,185.5],[-135.5,60],[-212.5,-67.5],[-77.5,-73.5],[0.5,-197.5],[97.5,-69.5],[224,-72.5],[151.5,66.5],[175.5,170.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-11.844,41.561],[0,0],[-49.158,6.145],[0,0],[-16.164,-70.086],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.156,-68.439],[0,0],[40,-5],[0,0],[7.493,29.682],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-145.003,186.831],[-137.164,65.657],[-219.156,-30.561],[-79.497,-71.171],[5.824,-195.503],[98.831,-65.507],[225.664,-23.914],[146.176,75.818],[168.844,176.823],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":20,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-81.495,-78.095],[8.5,-194.5],[100.495,-71.595],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-20.316,69.435],[0,0],[-49.158,6.145],[0,0],[-12.1,-30.041],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[20.184,-52.565],[0,0],[40,-5],[0,0],[25.833,76.256],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-147.993,183.507],[-137.002,64.174],[-217.184,-43.935],[-78.171,-73.328],[14.823,-198.161],[98.169,-67.493],[226.167,-45.756],[146.828,73.512],[169.826,175.008],[81.334,174.836]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":24,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-21.684,66.85],[0,0],[-49.158,6.145],[0,0],[-15.522,-25.819],[0,0],[10.436,-16.401],[0,0]],"o":[[0,0],[-17,-14],[0,0],[16.363,-33.241],[0,0],[40,-5],[0,0],[25.946,46.431],[0,0],[-12.974,20.266],[0,0]],"v":[[-67,178],[-149.347,182.153],[-136.663,62.708],[-210.316,-61.85],[-77.381,-74.455],[16.965,-199.401],[97.718,-68.847],[226.054,-61.431],[147.956,71.143],[171.292,173.317],[81.109,174.272]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":25,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-150,181.5],[-136.5,62],[-207,-70.5],[-77,-85.184],[18,-200],[99.486,-81.721],[226,-69],[148.5,70],[172,172.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-10.685,32.799],[0,0],[-49.352,4.05],[0,0],[-8.833,-66.253],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.313,-66.092],[0,0],[35.341,-3.003],[0,0],[5.931,29.697],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-146.007,185.493],[-138.829,68.988],[-219.313,-25.908],[-79.825,-83.945],[17.002,-195.341],[99.992,-80.003],[226.333,-20.747],[145.172,81.314],[167.674,177.492],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":30,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.45,2.997],[0,0],[1,-35],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[33,-2],[0,0],[-0.887,31.052],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-140,72.5],[-225.5,-3.5],[-84.461,-85.898],[15.014,-179.153],[100.995,-77.872],[226.5,3.5],[143.5,87],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":33,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-26.152,78.596],[0,0],[-49.255,5.092],[0,0],[-11.096,-27.533],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[12.338,-28.456],[0,0],[37.659,-3.997],[0,0],[20.664,87.083],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-145.997,186.169],[-137.005,64.181],[-216.848,-46.096],[-78.998,-92.417],[4.862,-177.968],[96.526,-84.838],[224.836,-47.082],[148.824,73.356],[172.156,173.677],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":35,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-147,185.5],[-135.5,60],[-212.5,-67.5],[-77.5,-95.711],[2.969,-175.289],[95.525,-88.611],[224,-72.5],[151.5,66.5],[175.5,170.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-11.844,41.561],[0,0],[-49.158,6.145],[0,0],[-16.164,-70.086],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.156,-68.439],[0,0],[40,-5],[0,0],[7.493,29.682],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-145.003,186.831],[-137.164,65.657],[-219.156,-30.561],[-78.51,-91.396],[4.345,-177.87],[93.9,-92.474],[225.664,-23.914],[146.176,75.818],[168.844,176.823],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":40,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-81.485,-90.796],[5.544,-174.223],[100.485,-81.177],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":43,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-20.316,69.435],[0,0],[-49.158,6.145],[0,0],[-12.1,-30.041],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[20.184,-52.565],[0,0],[40,-5],[0,0],[25.833,76.256],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-147.993,183.507],[-137.002,64.174],[-217.184,-43.935],[-75.71,-88.978],[10.394,-176.25],[97.185,-89.404],[226.167,-45.756],[146.828,73.512],[169.826,175.008],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":45,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-150,181.5],[-136.5,62],[-207,-70.5],[-76.017,-97.999],[11.608,-174.91],[97.008,-87.272],[226,-69],[148.5,70],[172,172.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-10.685,32.799],[0,0],[-49.352,4.05],[0,0],[-8.833,-66.253],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.313,-66.092],[0,0],[35.341,-3.003],[0,0],[5.931,29.697],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-146.007,185.493],[-138.829,68.988],[-219.313,-25.908],[-79.329,-92.109],[10.616,-167.043],[98.514,-89.803],[226.333,-20.747],[145.172,81.314],[167.674,177.492],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":50,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.262,5.247],[0,0],[1,-35],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[45.995,-4.899],[0,0],[-0.887,31.052],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-140,72.5],[-225.5,-3.5],[-79.518,-90.465],[11.59,-162.564],[97.554,-81.366],[226.5,3.5],[143.5,87],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":53,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-26.152,78.596],[0,0],[-49.255,5.092],[0,0],[-11.096,-27.533],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[13.353,-39.632],[0,0],[43.855,-3.783],[0,0],[20.664,87.082],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-145.997,186.169],[-137.005,64.18],[-216.848,-46.096],[-76.05,-97.557],[6.342,-167.614],[93.106,-90.977],[224.836,-47.082],[148.824,73.356],[172.156,173.677],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":55,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.179,5.975],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[48.213,-5.858],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-147,185.5],[-135.5,60],[-212.5,-67.5],[-73.085,-99.798],[2.462,-172.78],[95.047,-87.383],[224,-72.5],[151.5,66.5],[175.5,170.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-11.844,41.562],[0,0],[-49.158,6.145],[0,0],[-16.164,-70.086],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.156,-68.439],[0,0],[40,-5],[0,0],[7.493,29.682],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-145.003,186.831],[-137.164,65.657],[-219.156,-30.561],[-76.554,-96.959],[6.315,-178.136],[96.379,-83.927],[225.664,-23.914],[146.176,75.818],[168.844,176.823],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":60,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":64,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[7.39,-22.687],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-7.498,23.017],[0,0]],"v":[[-67,178],[-167.273,169.551],[-138,68.5],[-216.558,-37.642],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[225.014,-20.014],[143.5,80.5],[180.85,166.153],[82,176.5]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[7.39,-22.687],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-7.498,23.017],[0,0]],"v":[[-67,178],[-167.273,169.551],[-138,68.5],[-216.558,-37.642],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[225.014,-20.014],[143.5,80.5],[180.85,166.153],[82,176.5]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":73,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-52.5,1],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[49.615,-0.945],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144.5,198.5],[-139,75],[-224.5,16.5],[-79.5,-66],[9,-188],[101,-58.5],[227,24.5],[143.5,84.5],[155.5,195],[82,176.5]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":83,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-150,182],[-137.5,63.5],[-223.5,-26.5],[-80.5,-75],[9,-197],[99,-66.5],[227,-11.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":93,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-20.316,69.435],[0,0],[-49.158,6.145],[0,0],[-12.1,-30.041],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[20.184,-52.565],[0,0],[40,-5],[0,0],[25.833,76.256],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-147.993,183.507],[-137.002,64.174],[-217.184,-43.935],[-78.171,-73.328],[14.823,-198.161],[98.169,-67.493],[226.167,-45.756],[146.828,73.512],[169.826,175.008],[81.334,174.836]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":94,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-21.684,66.85],[0,0],[-49.158,6.145],[0,0],[-15.522,-25.819],[0,0],[10.436,-16.401],[0,0]],"o":[[0,0],[-17,-14],[0,0],[16.363,-33.241],[0,0],[40,-5],[0,0],[25.946,46.431],[0,0],[-12.974,20.266],[0,0]],"v":[[-67,178],[-149.347,182.153],[-136.663,62.708],[-210.316,-61.85],[-77.381,-74.455],[16.965,-199.401],[97.718,-68.847],[226.054,-61.431],[147.956,71.143],[171.292,173.317],[81.109,174.272]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":95,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-150,181.5],[-136.5,62],[-207,-70.5],[-77,-75],[18,-200],[97.5,-69.5],[226,-69],[148.5,70],[172,172.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":98,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-10.685,32.799],[0,0],[-49.352,4.05],[0,0],[-8.833,-66.253],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.313,-66.092],[0,0],[35.341,-3.003],[0,0],[5.931,29.697],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-146.007,185.493],[-138.829,68.988],[-219.313,-25.908],[-79.329,-71.672],[17.002,-195.341],[99.497,-65.174],[226.333,-20.747],[145.172,81.314],[167.674,177.492],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":100,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.45,2.997],[0,0],[1,-35],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[33,-2],[0,0],[-0.887,31.052],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-140,72.5],[-225.5,-3.5],[-80.5,-70],[16.5,-193],[100.5,-63],[226.5,3.5],[143.5,87],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":103,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-26.152,78.596],[0,0],[-49.255,5.092],[0,0],[-11.096,-27.533],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[12.338,-28.456],[0,0],[37.659,-3.997],[0,0],[20.664,87.082],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-145.997,186.169],[-137.005,64.181],[-216.848,-46.096],[-78.503,-72.329],[5.851,-195.995],[98.503,-67.326],[224.836,-47.082],[148.824,73.356],[172.156,173.677],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":105,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-147,185.5],[-135.5,60],[-212.5,-67.5],[-77.5,-73.5],[0.5,-197.5],[97.5,-69.5],[224,-72.5],[151.5,66.5],[175.5,170.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":108,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-11.844,41.561],[0,0],[-49.158,6.145],[0,0],[-16.164,-70.086],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.156,-68.439],[0,0],[40,-5],[0,0],[7.493,29.682],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-145.003,186.831],[-137.164,65.657],[-219.156,-30.561],[-79.497,-71.171],[5.824,-195.503],[98.831,-65.507],[225.664,-23.914],[146.176,75.818],[168.844,176.823],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":110,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-81.495,-78.095],[8.5,-194.5],[100.495,-71.595],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":113,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-20.316,69.435],[0,0],[-49.158,6.145],[0,0],[-12.1,-30.041],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[20.184,-52.565],[0,0],[40,-5],[0,0],[25.833,76.256],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-147.993,183.507],[-137.002,64.174],[-217.184,-43.935],[-78.171,-73.328],[14.823,-198.161],[98.169,-67.493],[226.167,-45.756],[146.828,73.512],[169.826,175.008],[81.334,174.836]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":114,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-21.684,66.85],[0,0],[-49.158,6.145],[0,0],[-15.522,-25.819],[0,0],[10.436,-16.401],[0,0]],"o":[[0,0],[-17,-14],[0,0],[16.363,-33.241],[0,0],[40,-5],[0,0],[25.946,46.431],[0,0],[-12.974,20.266],[0,0]],"v":[[-67,178],[-149.347,182.153],[-136.663,62.708],[-210.316,-61.85],[-77.381,-74.455],[16.965,-199.401],[97.718,-68.847],[226.054,-61.431],[147.956,71.143],[171.292,173.317],[81.109,174.272]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":115,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-150,181.5],[-136.5,62],[-207,-70.5],[-77,-85.184],[18,-200],[99.486,-81.721],[226,-69],[148.5,70],[172,172.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":118,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-10.685,32.799],[0,0],[-49.352,4.05],[0,0],[-8.833,-66.253],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.313,-66.092],[0,0],[35.341,-3.003],[0,0],[5.931,29.697],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-146.007,185.493],[-138.829,68.988],[-219.313,-25.908],[-79.825,-83.945],[17.002,-195.341],[99.992,-80.003],[226.333,-20.747],[145.172,81.314],[167.674,177.492],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":120,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.45,2.997],[0,0],[1,-35],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[33,-2],[0,0],[-0.887,31.052],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-140,72.5],[-225.5,-3.5],[-84.461,-85.898],[15.014,-179.153],[100.995,-77.872],[226.5,3.5],[143.5,87],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":123,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-26.152,78.596],[0,0],[-49.255,5.092],[0,0],[-11.096,-27.533],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[12.338,-28.456],[0,0],[37.659,-3.997],[0,0],[20.664,87.083],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-145.997,186.169],[-137.005,64.181],[-216.848,-46.096],[-78.998,-92.417],[4.862,-177.968],[96.526,-84.838],[224.836,-47.082],[148.824,73.356],[172.156,173.677],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":125,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-147,185.5],[-135.5,60],[-212.5,-67.5],[-77.5,-95.711],[2.969,-175.289],[95.525,-88.611],[224,-72.5],[151.5,66.5],[175.5,170.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":128,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-11.844,41.561],[0,0],[-49.158,6.145],[0,0],[-16.164,-70.086],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.156,-68.439],[0,0],[40,-5],[0,0],[7.493,29.682],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-145.003,186.831],[-137.164,65.657],[-219.156,-30.561],[-78.51,-91.396],[4.345,-177.87],[93.9,-92.474],[225.664,-23.914],[146.176,75.818],[168.844,176.823],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":130,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-81.485,-90.796],[5.544,-174.223],[100.485,-81.177],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":133,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-20.316,69.435],[0,0],[-49.158,6.145],[0,0],[-12.1,-30.041],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[20.184,-52.565],[0,0],[40,-5],[0,0],[25.833,76.256],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-147.993,183.507],[-137.002,64.174],[-217.184,-43.935],[-75.71,-88.978],[10.394,-176.25],[97.185,-89.404],[226.167,-45.756],[146.828,73.512],[169.826,175.008],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":135,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.158,6.145],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[40,-5],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-150,181.5],[-136.5,62],[-207,-70.5],[-76.017,-97.999],[11.608,-174.91],[97.008,-87.272],[226,-69],[148.5,70],[172,172.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":138,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-10.685,32.799],[0,0],[-49.352,4.05],[0,0],[-8.833,-66.253],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.313,-66.092],[0,0],[35.341,-3.003],[0,0],[5.931,29.697],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-146.007,185.493],[-138.829,68.988],[-219.313,-25.908],[-79.329,-92.109],[10.616,-167.043],[98.514,-89.803],[226.333,-20.747],[145.172,81.314],[167.674,177.492],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":140,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.262,5.247],[0,0],[1,-35],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[45.995,-4.899],[0,0],[-0.887,31.052],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-140,72.5],[-225.5,-3.5],[-79.518,-90.465],[11.59,-162.564],[97.554,-81.366],[226.5,3.5],[143.5,87],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":143,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-26.152,78.596],[0,0],[-49.255,5.092],[0,0],[-11.096,-27.533],[0,0],[11.338,-15.161],[0,0]],"o":[[0,0],[-17,-14],[0,0],[13.353,-39.632],[0,0],[43.855,-3.783],[0,0],[20.664,87.082],[0,0],[-14.425,19.026],[0,0]],"v":[[-67,178],[-145.997,186.169],[-137.005,64.18],[-216.848,-46.096],[-76.05,-97.557],[6.342,-167.614],[93.106,-90.977],[224.836,-47.082],[148.824,73.356],[172.156,173.677],[81.334,174.836]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":145,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-17,28],[0,0],[-49.179,5.975],[0,0],[-17.175,-23.781],[0,0],[10,-17],[0,0]],"o":[[0,0],[-17,-14],[0,0],[14.517,-23.911],[0,0],[48.213,-5.858],[0,0],[19.5,27],[0,0],[-12.274,20.865],[0,0]],"v":[[-67,178],[-147,185.5],[-135.5,60],[-212.5,-67.5],[-73.085,-99.798],[2.462,-172.78],[95.047,-87.383],[224,-72.5],[151.5,66.5],[175.5,170.5],[81,174]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.167,"y":0.167},"t":148,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-11.844,41.562],[0,0],[-49.158,6.145],[0,0],[-16.164,-70.086],[0,0],[12.662,-13.339],[0,0]],"o":[[0,0],[-17,-14],[0,0],[17.156,-68.439],[0,0],[40,-5],[0,0],[7.493,29.682],[0,0],[-16.555,17.205],[0,0]],"v":[[-67,178],[-145.003,186.831],[-137.164,65.657],[-219.156,-30.561],[-76.554,-96.959],[6.315,-178.136],[96.379,-83.927],[225.664,-23.914],[146.176,75.818],[168.844,176.823],[81.666,175.664]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.41,"y":0},"t":150,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":154,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[7.39,-22.687],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-7.498,23.017],[0,0]],"v":[[-67,178],[-167.273,169.551],[-138,68.5],[-216.558,-37.642],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[225.014,-20.014],[143.5,80.5],[180.85,166.153],[82,176.5]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.167,"y":0.167},"t":157,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[7.39,-22.687],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-7.498,23.017],[0,0]],"v":[[-67,178],[-167.273,169.551],[-138,68.5],[-216.558,-37.642],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[225.014,-20.014],[143.5,80.5],[180.85,166.153],[82,176.5]],"c":false}]},{"i":{"x":0.61,"y":1},"o":{"x":0.41,"y":0},"t":163,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-52.5,1],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[49.615,-0.945],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144.5,198.5],[-139,75],[-224.5,16.5],[-79.5,-66],[9,-188],[101,-58.5],[227,24.5],[143.5,84.5],[155.5,195],[82,176.5]],"c":false}]},{"i":{"x":0.59,"y":1},"o":{"x":0.41,"y":0},"t":173,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-150,182],[-137.5,63.5],[-223.5,-26.5],[-80.5,-75],[9,-197],[99,-66.5],[227,-11.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[19.718,16.239],[0,0],[-7.512,35.211],[0,0],[-49.158,6.145],[0,0],[-2,-42.5],[0,0],[14,-11.5],[0,0]],"o":[[0,0],[-17,-14],[0,0],[8,-37.5],[0,0],[40,-5],[0,0],[1.46,31.03],[0,0],[-18.706,15.365],[0,0]],"v":[[-67,178],[-144,187.5],[-138,68.5],[-222.5,-12],[-80.5,-70],[8.5,-194.5],[99.5,-63.5],[226.5,0.5],[143.5,80.5],[165.5,180],[82,176.5]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"STAR 2","sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.45],"y":[0]},"t":100,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":116,"s":[-2]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":123,"s":[1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":130,"s":[-2]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":137,"s":[1]},{"i":{"x":[0.833],"y":[0.583]},"o":{"x":[0.167],"y":[0.167]},"t":148,"s":[-2]},{"t":158,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.57,"y":1},"o":{"x":0.42,"y":0},"t":0,"s":[265.75,506.5,0],"to":[0,-1.917,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.42,"y":0},"t":60,"s":[265.75,495,0],"to":[0,0,0],"ti":[0,-1.917,0]},{"i":{"x":0.57,"y":0.57},"o":{"x":0.42,"y":0.42},"t":69,"s":[265.75,506.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.42,"y":0},"t":90,"s":[265.75,506.5,0],"to":[0,-1.917,0],"ti":[0,0,0]},{"i":{"x":0.57,"y":1},"o":{"x":0.42,"y":0},"t":150,"s":[265.75,495,0],"to":[0,0,0],"ti":[0,-1.917,0]},{"t":159,"s":[265.75,506.5,0]}]},"a":{"a":0,"k":[9.75,250.5,0]},"s":{"a":0,"k":[96,96,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-61.125,177.194],[-76,226],[-53.5,234.5]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-53.5,194],[-61.625,156.194],[-76,226],[-53.5,237]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[{"i":[[0,0],[12.625,3.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-65.625,191.194],[-76,226],[-53.5,234.5]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":79,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-61.125,177.194],[-76,226],[-53.5,234.5]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-61.125,177.194],[-76,226],[-53.5,234.5]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-53.5,194],[-61.625,156.194],[-76,226],[-53.5,237]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[{"i":[[0,0],[12.625,3.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-65.625,191.194],[-76,226],[-53.5,234.5]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":169,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-61.125,177.194],[-76,226],[-53.5,234.5]],"c":true}]},{"t":179,"s":[{"i":[[0,0],[6.625,7.806],[4,-11.5],[0,0]],"o":[[0,0],[-4.875,11.306],[14.5,6],[0,0]],"v":[[-52,203.5],[-61.125,177.194],[-76,226],[-53.5,234.5]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.090196078431,0.321568627451,0.298039215686,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784373564,0.694117647059,0.152941176471,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 8","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[{"i":[[11.214,33.195],[5.212,-32.39]],"o":[[-9.308,-27.55],[-4.673,29.041]],"v":[[33.786,203.886],[-16.103,206.864]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[{"i":[[8.332,34.033],[6.103,-42.864]],"o":[[-8.786,-35.886],[-4.146,29.121]],"v":[[33.786,198.386],[-15.103,198.864]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[{"i":[[10.376,33.466],[1.603,-27.364]],"o":[[-6.786,-21.886],[-1.72,29.364]],"v":[[34.786,206.386],[-17.103,210.364]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":79,"s":[{"i":[[11.214,33.195],[5.212,-32.39]],"o":[[-9.308,-27.55],[-4.673,29.041]],"v":[[33.786,203.886],[-16.103,206.864]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[11.214,33.195],[5.212,-32.39]],"o":[[-9.308,-27.55],[-4.673,29.041]],"v":[[33.786,203.886],[-16.103,206.864]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[{"i":[[8.332,34.033],[6.103,-42.864]],"o":[[-8.786,-35.886],[-4.146,29.121]],"v":[[33.786,198.386],[-15.103,198.864]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[{"i":[[10.376,33.466],[1.603,-27.364]],"o":[[-6.786,-21.886],[-1.72,29.364]],"v":[[34.786,206.386],[-17.103,210.364]],"c":true}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":169,"s":[{"i":[[11.214,33.195],[5.212,-32.39]],"o":[[-9.308,-27.55],[-4.673,29.041]],"v":[[33.786,203.886],[-16.103,206.864]],"c":true}]},{"t":179,"s":[{"i":[[11.214,33.195],[5.212,-32.39]],"o":[[-9.308,-27.55],[-4.673,29.041]],"v":[[33.786,203.886],[-16.103,206.864]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.090196078431,0.321568627451,0.298039215686,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.956862804936,0.815686334348,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 7","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-67,178],[5,84],[82,176.5],[100,222]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-66,146],[5,84],[82,147],[100,222]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":69,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-69,185.5],[5,84],[84,180],[100,222]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.43,"y":0},"t":79,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-67,178],[5,84],[82,176.5],[100,222]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.167,"y":0},"t":89,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-67,178],[5,84],[82,176.5],[100,222]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":150,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-66,146],[5,84],[82,147],[100,222]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":159,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-69,185.5],[5,84],[84,180],[100,222]],"c":false}]},{"i":{"x":0.58,"y":1},"o":{"x":0.43,"y":0},"t":169,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-67,178],[5,84],[82,176.5],[100,222]],"c":false}]},{"t":179,"s":[{"i":[[0,0],[0,0],[-52.5,9],[0,0],[0,0]],"o":[[0,0],[0,0],[40.906,-7.012],[0,0],[0,0]],"v":[[-80.5,223.5],[-67,178],[5,84],[82,176.5],[100,222]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":10},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-56,0],[0,0]],"o":[[0,0],[0,0],[0,0],[56,0],[0,0]],"v":[[80,194],[-69,194.5],[-79.5,221],[12,250.5],[95.5,221.5]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.662745098039,0.282352941176,0,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.898039275525,0.400000029919,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 2","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0}]} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 332adf8..fa75d89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,13 +9,11 @@ import { ContactPage } from "./pages/Contact/Contact"; import { AboutUs } from "./pages/AboutUs/AboutUs"; import { SupportPage } from "./pages/SupportPage/SupportPage"; import { FaqPage } from "./pages/FaqPage/FaqPage"; -import SignIn from "./pages/Auth/SignIn"; -import SignUp from "./pages/Auth/SignUp"; -import SignOut from "./pages/Auth/SignOut"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; import { Toaster } from 'react-hot-toast'; +import { AuthPage } from "./pages/Auth/AuthPage"; export const App = () => { return ( @@ -44,12 +42,8 @@ export const App = () => { } /> - - } /> - } /> - } /> - } /> - + } /> + } /> diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c2e990a..26ec5be 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -56,7 +56,7 @@ export const Header = () => {
        ) : ( - + Увійти )} diff --git a/frontend/src/components/TournamentCard.tsx b/frontend/src/components/TournamentCard.tsx index 3dce75f..70a30bb 100644 --- a/frontend/src/components/TournamentCard.tsx +++ b/frontend/src/components/TournamentCard.tsx @@ -11,7 +11,7 @@ const STATUS_CFG = { gradTo: "#059669", icon: , btnText: "Подати заявку", - btnClass: "bg-primary text-white hover:bg-indigo-600", + btnClass: "bg-primary text-white hover:bg-indigo-600 shadow-sm", }, active: { label: "В процесі", @@ -36,7 +36,8 @@ const STATUS_CFG = { ), btnText: "Переглянути результати", - btnClass: "bg-bg-body text-slate-400 cursor-default", + btnClass: + "bg-slate-50 text-slate-400 border border-slate-200 cursor-default", }, }; @@ -62,9 +63,9 @@ export const TournamentCard = ({ const gradId = `grad-${title.replace(/\s+/g, "-")}`; return ( -
        -
        -
        +
        +
        +
        @@ -86,75 +87,80 @@ export const TournamentCard = ({ {cfg.icon}
        -

        +

        {title}

        -
        +
        {tags.map((tag, i) => ( {tag.label} ))}
        -

        +

        {desc}

        -
        -
        - - Команди - - - {teams}{" "} - / {max} - -
        -
        -
        +
        +
        +
        + + Команди + + + {teams}{" "} + / {max} + +
        +
        +
        +
        -
        -
        -
        - +
        + + + + До {deadline} +
        + - - - До {deadline} + + {cfg.label} +
        - - - {cfg.label} - + {cfg.btnText} +
        - -
        ); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..f3e1872 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,67 @@ +import { type ReactNode } from "react"; +import { motion, type HTMLMotionProps } from "framer-motion"; +import { Loader2 } from "lucide-react"; + +type ButtonVariant = "primary" | "accent" | "outline" | "ghost"; +type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonProps extends Omit, "ref"> { + variant?: ButtonVariant; + size?: ButtonSize; + isLoading?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; +} + +const variantStyles: Record = { + primary: + "bg-indigo-500 text-white shadow-[0_12px_28px_rgba(99,102,241,0.35)] hover:bg-indigo-600 hover:shadow-[0_16px_32px_rgba(99,102,241,0.4)] border-2 border-transparent", + accent: + "bg-amber-400 text-slate-900 shadow-[0_12px_28px_rgba(251,191,36,0.3)] hover:bg-amber-500 hover:shadow-[0_16px_32px_rgba(251,191,36,0.4)] border-2 border-transparent", + outline: + "bg-transparent border-2 border-slate-200 text-slate-900 hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.15)]", + ghost: + "bg-transparent border-2 border-transparent text-slate-500 hover:text-slate-900 hover:bg-slate-100", +}; + +const sizeStyles: Record = { + sm: "py-2 px-5 text-[14px]", + md: "py-3.5 px-6 text-[15px]", + lg: "py-4 px-10 text-[17px]", +}; + +export const Button = ({ + variant = "primary", + size = "md", + isLoading = false, + leftIcon, + rightIcon, + children, + className = "", + disabled, + ...props +}: ButtonProps) => { + const isDisabled = disabled || isLoading; + + return ( + + {isLoading && } + {!isLoading && leftIcon} + {children} + {!isLoading && rightIcon} + + ); +}; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..c5676d3 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./Button"; +// export * from "./Card"; diff --git a/frontend/src/pages/Auth/AuthPage.tsx b/frontend/src/pages/Auth/AuthPage.tsx new file mode 100644 index 0000000..d5f1474 --- /dev/null +++ b/frontend/src/pages/Auth/AuthPage.tsx @@ -0,0 +1,389 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { motion, AnimatePresence } from "framer-motion"; +import { Eye, EyeOff } from "lucide-react"; +import { Player } from "@lottiefiles/react-lottie-player"; +import { Link, useNavigate } from "react-router-dom"; +import { Button } from "../../components/ui"; + +import { + createUserWithEmailAndPassword, + signInWithEmailAndPassword, + updateProfile, + signInWithPopup, +} from "firebase/auth"; +import type { FirebaseError } from "firebase/app"; +import { auth, google } from "../../firebase"; +import { store } from "../../store"; +import { setDisplayName } from "../../slices/user"; + +import starAnimation from "../../../public/star.json"; + +const GoogleIcon = () => ( + + + + + + +); + +const authSchema = z + .object({ + mode: z.enum(["login", "register"]), + displayName: z.string().optional(), + email: z.string().email("Некоректний формат email"), + password: z.string().min(8, "Мінімум 8 символів"), + }) + .superRefine((data, ctx) => { + if ( + data.mode === "register" && + (!data.displayName || data.displayName.length < 2) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Нікнейм обов'язковий (мінімум 2 символи)", + path: ["displayName"], + }); + } + }); + +type AuthFormData = z.infer; + +export const AuthPage = () => { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [firebaseError, setFirebaseError] = useState(null); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(authSchema), + defaultValues: { + mode: "register", + displayName: "", + email: "", + password: "", + }, + }); + + const passwordValue = watch("password"); + const isPasswordValid = passwordValue?.length >= 8; + + const toggleMode = (mode: "login" | "register") => { + setIsLogin(mode === "login"); + setValue("mode", mode); + setFirebaseError(null); + }; + + const onSubmit = async (data: AuthFormData) => { + setFirebaseError(null); + try { + if (isLogin) { + await signInWithEmailAndPassword(auth, data.email, data.password); + } else { + const cred = await createUserWithEmailAndPassword( + auth, + data.email, + data.password, + ); + if (data.displayName) { + await updateProfile(cred.user, { displayName: data.displayName }); + store.dispatch(setDisplayName(data.displayName)); + } + } + navigate("/"); + } catch (e) { + const err = e as FirebaseError; + if (err.code === "auth/email-already-in-use") + setFirebaseError("Цей email вже використовується."); + else if ( + err.code === "auth/invalid-credential" || + err.code === "auth/wrong-password" + ) + setFirebaseError("Невірний email або пароль."); + else setFirebaseError("Сталася помилка. Спробуйте ще раз."); + } + }; + + const handleGoogleSignIn = async () => { + try { + await signInWithPopup(auth, google); + navigate("/"); + } catch (error) { + console.error("Google Sign-In Error:", error); + } + }; + + return ( + <> + + +
        +
        +
        +
        + + UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★  + + + UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★  + +
        +
        +
        +
        + + STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★  + + + STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★  + +
        +
        + +
        + +
        +
        + UGalaxy +
        +
        + × +
        +
        + Star for Life +
        +
        +

        + Твоя історія починається тут. Створюй, втілюй, змінюй світ. +

        +
        + + {/* Легка, м'яка хвилька */} +
        + + + +
        +
        + +
        + +
        +

        + {isLogin ? "З поверненням!" : "Створити акаунт"} +

        +

        + {isLogin + ? "Продовжуйте свій шлях в UGalaxy" + : "Готовий до нових челенджів?"} +

        +
        + +
        + + + +
        + +
        + + {!isLogin && ( + +
        + + + {errors.displayName && ( + + {errors.displayName.message} + + )} +
        +
        + )} +
        + +
        + + + {errors.email && ( + + {errors.email.message} + + )} +
        + +
        +
        + + + Забули пароль? + +
        +
        + + +
        + {errors.password && ( + + {errors.password.message} + + )} +
        + + {firebaseError && ( +
        + {firebaseError} +
        + )} + + + +
        +
        + + або + +
        +
        + + + +

        + {isLogin ? "Входячи" : "Реєструючись"}, ти погоджуєшся з
        + + Умовами використання + {" "} + та{" "} + + Політикою конфіденційності + +

        +
        +
        +
        +
        + + ); +}; diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx index fd4b656..d728c70 100644 --- a/frontend/src/pages/Auth/SignUp.tsx +++ b/frontend/src/pages/Auth/SignUp.tsx @@ -6,68 +6,95 @@ import { createUserWithEmailAndPassword, updateProfile } from "firebase/auth"; import { setDisplayName } from "../../slices/user"; import { store } from "../../store"; import type { FirebaseError } from "firebase/app"; -import { getTranslation } from '@firebase-oss/ui-core'; +import { getTranslation } from "@firebase-oss/ui-core"; const SignUp = () => { - const navigate = useNavigate(); - // The reason we use our own form and not SignUpAuthScreen is - // the display name is set after the user creation using updateProfile - // Because of it the displayName is empty in redux when you register an account - // There is no way to track this event somehow, so we have to update the profile ourselves - const ui = useUI(); - const [formData, setFormData] = useState({ - displayName: '', - email: '', - password: '', + const navigate = useNavigate(); + // The reason we use our own form and not SignUpAuthScreen is + // the display name is set after the user creation using updateProfile + // Because of it the displayName is empty in redux when you register an account + // There is no way to track this event somehow, so we have to update the profile ourselves + const ui = useUI(); + const [formData, setFormData] = useState({ + displayName: "", + email: "", + password: "", + }); + const [formErrors, setFormErrors] = useState>({ + email: null, + password: null, + }); + const handleUpdate = (e: ChangeEvent) => { + const target = e.target; + setFormData({ + ...formData, + [target.name]: target.value, }); - const [formErrors, setFormErrors] = useState>({ - email: null, - password: null, - }) - const handleUpdate = (e: ChangeEvent) => { - const target = e.target; - setFormData({ - ...formData, - [target.name]: target.value, + }; + const handleSignUp = async (e: SubmitEvent) => { + e.preventDefault(); + if (!formData.displayName || !formData.email || !formData.password) return; + try { + const cred = await createUserWithEmailAndPassword( + auth, + formData.email, + formData.password, + ); + await updateProfile(cred.user, { + displayName: formData.displayName, + }); + store.dispatch(setDisplayName(formData.displayName)); + navigate("/"); + } catch (e) { + const err = e as FirebaseError; + console.log(err.code); + // TODO?: Maybe we can find a way to handle the errors properly + if (err.code == "auth/email-already-in-use") { + setFormErrors({ + ...formErrors, + email: getTranslation(ui, "errors", "emailAlreadyInUse"), }); + } else if (err.code == "auth/password-does-not-meet-requirements") { + setFormErrors({ + ...formErrors, + password: getTranslation(ui, "errors", "weakPassword"), + }); + } } - const handleSignUp = async (e: SubmitEvent) => { - e.preventDefault(); - if (!formData.displayName || !formData.email || !formData.password) return; - try { - const cred = await createUserWithEmailAndPassword(auth, formData.email, formData.password); - await updateProfile(cred.user, { - displayName: formData.displayName - }); - store.dispatch(setDisplayName(formData.displayName)); - navigate('/'); - } catch (e) { - const err = e as FirebaseError; - console.log(err.code); - // TODO?: Maybe we can find a way to handle the errors properly - if (err.code == 'auth/email-already-in-use') { - setFormErrors({ - ...formErrors, - email: getTranslation(ui, 'errors', 'emailAlreadyInUse') - }); - } else if (err.code == 'auth/password-does-not-meet-requirements') { - setFormErrors({ - ...formErrors, - password: getTranslation(ui, 'errors', 'weakPassword') - }); - } - } - }; - return
        - - - - {formErrors.email &&

        {formErrors.email}

        } - - {formErrors.password &&

        {formErrors.password}

        } - - Вже маєте аккаунт? Увійдіть у нього! + }; + return ( + + + + + {formErrors.email &&

        {formErrors.email}

        } + + {formErrors.password &&

        {formErrors.password}

        } + + Вже маєте аккаунт? Увійдіть у нього! -} + ); +}; -export default SignUp; \ No newline at end of file +export default SignUp; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index aeb74a1..a5254cc 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -45,7 +45,7 @@ export const Home = () => { ]} mascot={{ circularText: "★ ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ", - lottieSrc: "/hedgehog.json", + lottieSrc: "/star.json", buttonText: "Долучитись", buttonLink: "/register", }} diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx index 70f634d..f0f2547 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, type ReactNode } from "react"; import { useParams } from "react-router-dom"; -import apiClient from "@/api/client"; +import apiClient from "@/api/client"; import { Hero } from "../../components/Hero"; // --- ТИПІЗАЦІЯ --- @@ -92,12 +92,10 @@ export const TournamentPage = () => { try { setIsLoading(true); - // Використовуємо apiClient замість fetch const response = await apiClient.get(`/tournaments/${id}`); setTournament(response.data); setError(null); } catch (err: any) { - // Обробка помилок відповідно до Swagger (422 Validation Error або загальні) const errorMessage = err.response?.data?.detail?.[0]?.msg || err.response?.data?.message || @@ -115,7 +113,6 @@ export const TournamentPage = () => { const currentStatus: TourneyStatus = "active"; const statusInfo = STATUS_CONFIG[currentStatus]; - // Стан завантаження if (isLoading) { return (
        @@ -127,7 +124,6 @@ export const TournamentPage = () => { ); } - // Стан помилки if (error || !tournament) { return (
        @@ -150,7 +146,6 @@ export const TournamentPage = () => { return (
        - (

        - {/* Заглушка під статичні дані, якщо згодом з'являться на бекенді */}

        @@ -280,7 +274,6 @@ const DescriptionTab = ({ description }: { description: string }) => ( Ключові вимоги:

          - {/* Приклад статичного контенту */}
        • Створити інноваційний проект з використанням GameDev підходів.

          diff --git a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx index 234e423..5b7cda3 100644 --- a/frontend/src/pages/TournamentsPage/TournamentsPage.tsx +++ b/frontend/src/pages/TournamentsPage/TournamentsPage.tsx @@ -1,4 +1,7 @@ import { useState, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Search, ChevronLeft, ChevronRight } from "lucide-react"; + import { Hero } from "../../components/Hero"; import { TournamentCard } from "../../components/TournamentCard"; import { @@ -8,6 +11,17 @@ import { const PER_PAGE = 15; +const FILTER_OPTIONS: { + id: TournamentStatus | "all"; + label: string; + dotColor?: string; +}[] = [ + { id: "all", label: "Всі події" }, + { id: "registration", label: "Реєстрація", dotColor: "bg-green-500" }, + { id: "active", label: "В процесі", dotColor: "bg-pink-500" }, + { id: "completed", label: "Архів", dotColor: "bg-slate-400" }, +]; + export const TournamentsPage = () => { const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); @@ -39,6 +53,23 @@ export const TournamentsPage = () => { setPage(1); }; + const containerVariants = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { staggerChildren: 0.1 }, + }, + }; + + const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { type: "spring", stiffness: 300, damping: 24 }, + }, + }; + return (
          {
          - - - + {
          - - - - + {FILTER_OPTIONS.map((option) => ( + + ))}
          @@ -110,23 +121,45 @@ export const TournamentsPage = () => { турнірів
          - {currentData.length > 0 ? ( -
          - {currentData.map((tournament) => ( - - ))} -
          - ) : ( -
          - 🔍 -

          - Нічого не знайдено -

          -

          - Спробуй змінити фільтр або запит -

          -
          - )} + + {currentData.length > 0 ? ( + + {currentData.map((tournament) => ( + + + + ))} + + ) : ( + + 🔍 +

          + Нічого не знайдено +

          +

          + Спробуй змінити фільтр або запит +

          +
          + )} +
          {totalPages > 1 && (
          @@ -139,19 +172,7 @@ export const TournamentsPage = () => { disabled={page === 1} className="flex items-center gap-1 font-quicksand font-extrabold text-[14px] text-slate-500 hover:text-primary disabled:opacity-30 disabled:hover:text-slate-500 transition-colors" > - - - + Назад @@ -185,19 +206,7 @@ export const TournamentsPage = () => { className="flex items-center gap-1 font-quicksand font-extrabold text-[14px] text-slate-500 hover:text-primary disabled:opacity-30 disabled:hover:text-slate-500 transition-colors" > Вперед - - - +
          diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..859d3e6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "StarForLife", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 845a7895476eb6879ac06ab461b537ff9a298804 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 14:35:09 +0300 Subject: [PATCH 125/369] feat(rules&roleRequest): Add rule page and RoleRequets button on main page --- frontend/src/App.tsx | 2 + frontend/src/pages/Home/Home.tsx | 37 ++++- .../Home/components/TournamentSlider.tsx | 12 +- frontend/src/pages/Rules/Rules.tsx | 139 ++++++++++++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/Rules/Rules.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa75d89..5185ead 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { ContactPage } from "./pages/Contact/Contact"; import { AboutUs } from "./pages/AboutUs/AboutUs"; import { SupportPage } from "./pages/SupportPage/SupportPage"; import { FaqPage } from "./pages/FaqPage/FaqPage"; +import { RulesPage } from "./pages/Rules/Rules"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; @@ -39,6 +40,7 @@ export const App = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index a5254cc..32cdbf3 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,3 +1,4 @@ +import { Link } from "react-router-dom"; import { Hero } from "../../components/Hero"; import { TournamentSlider } from "./components/TournamentSlider"; @@ -47,10 +48,42 @@ export const Home = () => { circularText: "★ ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ", lottieSrc: "/star.json", buttonText: "Долучитись", - buttonLink: "/register", + buttonLink: "/tournaments", }} /> + + +
          +
          +
          +
          + +
          + + Нові можливості + +

          + Хочеш більше впливу на платформі? +

          +

          + Подай заявку на отримання нової ролі та розблокуй додатковий функціонал для себе та своєї команди. +

          +
          + +
          + + Отримати роль + + + + +
          +
          +
          ); -}; +}; \ No newline at end of file diff --git a/frontend/src/pages/Home/components/TournamentSlider.tsx b/frontend/src/pages/Home/components/TournamentSlider.tsx index 43570f7..a5470a5 100644 --- a/frontend/src/pages/Home/components/TournamentSlider.tsx +++ b/frontend/src/pages/Home/components/TournamentSlider.tsx @@ -1,10 +1,13 @@ import { useEffect, useRef, useCallback } from "react"; import { TournamentCard } from "../../../components/TournamentCard"; import { TOURNAMENTS_DATA } from "../../../data/mockTournaments"; +import { useNavigate } from "react-router-dom"; export const TournamentSlider = () => { const sliderRef = useRef(null); const progressBarRef = useRef(null); + const navigate = useNavigate(); + const isPaused = useRef(false); const progressRef = useRef(0); @@ -75,9 +78,14 @@ export const TournamentSlider = () => {

          - Обери свій напрямок + Знайди свій турнір

          - +
          { + const rules = [ + { + id: '01', + title: 'Взаємоповага — понад усе', + description: 'Ми створюємо безпечне середовище для кожного. Будьте ввічливими, поважайте думку інших учасників, менторів та організаторів. Дискримінація, булінг чи хейт тут неприпустимі.', + color: 'text-purple-500', + bgHover: 'hover:shadow-purple-500/20', + icon: ( + + + + ) + }, + { + id: '02', + title: 'Чесна гра (Fair Play)', + description: 'Всі турніри та завдання повинні виконуватися самостійно або разом із вашою командою. Плагіат, використання чужих робіт без дозволу або шахрайство призводять до дискваліфікації.', + color: 'text-blue-500', + bgHover: 'hover:shadow-blue-500/20', + icon: ( + + + + ) + }, + { + id: '03', + title: 'Командна робота та підтримка', + description: 'UGalaxy x Star for Life — це про співпрацю. Допомагайте одне одному, діліться знаннями та працюйте як єдиний механізм. Разом ви здатні на більше!', + color: 'text-teal-500', + bgHover: 'hover:shadow-teal-500/20', + icon: ( + + + + ) + }, + { + id: '04', + title: 'Безпека в інтернеті', + description: 'Бережіть свої персональні дані. Не діліться паролями, адресами чи іншою конфіденційною інформацією у відкритих чатах. Якщо помітили щось підозріле — одразу пишіть модераторам.', + color: 'text-amber-500', + bgHover: 'hover:shadow-amber-500/20', + icon: ( + + + + ) + }, + { + id: '05', + title: 'Креативність без меж', + description: 'Не бійтеся експериментувати! Ми цінуємо нестандартні ідеї, сміливі рішення та креативний підхід до виконання завдань. Головне правило — виходьте за рамки звичного!', + color: 'text-pink-500', + bgHover: 'hover:shadow-pink-500/20', + icon: ( + + + + ) + } + ]; + + return ( +
          +
          +
          +
          +
          + + + Конституція Платформи + +

          + ПРАВИЛА +

          +

          + Щоб перебування на платформі було комфортним та продуктивним для всіх, ми просимо дотримуватися кількох простих, але важливих правил. +

          +
          +
          + {rules.map((rule, index) => ( +
          +
          + {rule.id} +
          + +
          +
          + {rule.icon} +
          + +
          +
          + + {rule.id}. + +

          + {rule.title} +

          +
          +

          + {rule.description} +

          +
          +
          +
          + ))} + +
          +
          + +

          + Згодні з правилами? Тоді вперед до перемог! 🚀 +

          +

          + Обирай свій перший турнір, збирай команду мрії та покажи, на що ти здатен. +

          + + + Перейти до турнірів + +
          +
          +
          + ); +}; + +export { RulesPage }; \ No newline at end of file From 54dd3d54477dc899f0dd2ba93e492071dfc75af5 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 8 Apr 2026 14:56:32 +0300 Subject: [PATCH 126/369] fix(schemas): NotificationPublic caused an error because of the wrong type of user field --- backend/app/schemas/notification.py | 3 +- frontend/package-lock.json | 43 +++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index c56fce8..b422442 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, Field, field_validator, ConfigDict +from .user import UserPublic class NotificationBase(BaseModel): @@ -8,7 +9,7 @@ class NotificationBase(BaseModel): user_id: int = Field(..., description="Notification receiver") class NotificationPublic(NotificationBase): - user: str + user: UserPublic class NotificationCreate(NotificationBase): @field_validator("body") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b757c6c..2752b56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -144,6 +144,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -506,6 +507,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -554,6 +556,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1280,6 +1283,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", @@ -1346,6 +1350,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.14.9", "@firebase/component": "0.7.1", @@ -1361,7 +1366,8 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@firebase/auth": { "version": "1.12.1", @@ -1812,6 +1818,7 @@ "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2806,6 +2813,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -2957,8 +2965,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3051,6 +3058,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3061,6 +3069,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3116,6 +3125,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3493,6 +3503,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3653,6 +3664,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3858,7 +3870,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cva": { "version": "1.0.0-beta.4", @@ -3971,8 +3984,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4148,6 +4160,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4431,6 +4444,7 @@ "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz", "integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/ai": "2.9.0", "@firebase/analytics": "0.10.20", @@ -5321,7 +5335,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5431,6 +5444,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5574,6 +5588,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5625,7 +5640,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5641,7 +5655,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -5700,6 +5713,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5709,6 +5723,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5738,14 +5753,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5830,7 +5845,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6256,6 +6272,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6359,6 +6376,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6726,6 +6744,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 1e9c033df862c3e377153ced7874f93dd2d827f3 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Wed, 8 Apr 2026 15:10:22 +0300 Subject: [PATCH 127/369] fix(schemas): NotificationPublic circular import error --- backend/app/schemas/__init__.py | 4 ++-- backend/app/schemas/notification.py | 2 +- backend/app/schemas/user.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 14a87ee..a89c9b2 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,7 +2,7 @@ from .task import TaskModel from .team import TeamModel, TeamMemberModel from .tournament import TournamentModels, TournamentStatusOptionModel -from .notification import NotificationPublic, NotificationCreate from .user import UserModel, UserPublic, UserUpdate, UserCreate, CurrentUser from .role_request import RoleRequestPublic, RoleRequestCreate -from .role import RoleCreate, RolePublic, RoleUpdate \ No newline at end of file +from .role import RoleCreate, RolePublic, RoleUpdate +from .notification import NotificationPublic, NotificationCreate \ No newline at end of file diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index b422442..3b0ccc4 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -1,5 +1,4 @@ from pydantic import BaseModel, Field, field_validator, ConfigDict -from .user import UserPublic class NotificationBase(BaseModel): @@ -8,6 +7,7 @@ class NotificationBase(BaseModel): body: str = Field(..., description="Notification body") user_id: int = Field(..., description="Notification receiver") +from .user import UserPublic class NotificationPublic(NotificationBase): user: UserPublic diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 319855b..4568076 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,6 +1,5 @@ from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from .role import RolePublic -from .notification import NotificationPublic class UserBase(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -37,6 +36,7 @@ def check_email(cls, value: str): return value.lower().strip() from .role_request import RoleRequestPublic +from .notification import NotificationPublic # Return notifications of current user only # TODO: add created_tournaments after the TournamentPublic model will be defined class CurrentUser(UserPublic): From 600afe187502af3836e79c3290a522b0baec822e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 15:17:35 +0300 Subject: [PATCH 128/369] chore(notification): Connect backend to notifications --- frontend/src/components/NotificationsDropdown.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/NotificationsDropdown.tsx b/frontend/src/components/NotificationsDropdown.tsx index 0548946..fc4400e 100644 --- a/frontend/src/components/NotificationsDropdown.tsx +++ b/frontend/src/components/NotificationsDropdown.tsx @@ -1,19 +1,20 @@ import { useState, useRef, useEffect } from "react"; -// import { useSelector } from "react-redux"; -// import { type RootState } from "../store"; +import { useSelector } from "react-redux"; +import { type RootState } from "../store"; export const NotificationsDropdown = () => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + /* const notifications = [ { body: "🚀 Турнір 'Зимова битва' розпочнеться за 2 години!" }, { body: "✅ Ваша заявка на проєкт Star for Life успішно прийнята. Вітаємо в команді!" }, { body: "👑 Адміністратор надав вам нову роль. Тепер ви можете створювати проєкти." }, { body: "🔔 Це просто тестове повідомлення, щоб перевірити, як працює скрол у менюшці, коли тексту дуже багато і повідомлень теж багато." } - ]; + ];*/ - // const notifications = useSelector((s: RootState) => s.user?.notifications || []); + const notifications = useSelector((s: RootState) => s.user?.notifications || []); const toggleMenu = () => setIsOpen(!isOpen); From 75be0b3ffe04978b7030af949b7bf3061819936e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 20:17:04 +0300 Subject: [PATCH 129/369] fix SignOut --- frontend/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5185ead..a6cb2c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; import { Toaster } from 'react-hot-toast'; import { AuthPage } from "./pages/Auth/AuthPage"; +import SignOut from "./pages/Auth/SignOut"; export const App = () => { return ( @@ -41,6 +42,7 @@ export const App = () => { } /> } /> } /> + } /> } /> From 5d35a31403db7247ecea9e5679983e7ebf180d0e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 8 Apr 2026 20:27:34 +0300 Subject: [PATCH 130/369] feat(notifications): Add sockets for notificatins --- .../src/components/NotificationsDropdown.tsx | 15 ++------- frontend/src/hooks/useNotificationsSocket.ts | 26 ++++++++++++++++ frontend/src/slices/notifications.ts | 31 +++++++++++++++++++ frontend/src/store.ts | 6 +++- 4 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 frontend/src/hooks/useNotificationsSocket.ts create mode 100644 frontend/src/slices/notifications.ts diff --git a/frontend/src/components/NotificationsDropdown.tsx b/frontend/src/components/NotificationsDropdown.tsx index fc4400e..c4fdb97 100644 --- a/frontend/src/components/NotificationsDropdown.tsx +++ b/frontend/src/components/NotificationsDropdown.tsx @@ -6,15 +6,7 @@ export const NotificationsDropdown = () => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - /* - const notifications = [ - { body: "🚀 Турнір 'Зимова битва' розпочнеться за 2 години!" }, - { body: "✅ Ваша заявка на проєкт Star for Life успішно прийнята. Вітаємо в команді!" }, - { body: "👑 Адміністратор надав вам нову роль. Тепер ви можете створювати проєкти." }, - { body: "🔔 Це просто тестове повідомлення, щоб перевірити, як працює скрол у менюшці, коли тексту дуже багато і повідомлень теж багато." } - ];*/ - - const notifications = useSelector((s: RootState) => s.user?.notifications || []); + const notifications = useSelector((s: RootState) => s.notifications?.items || []); const toggleMenu = () => setIsOpen(!isOpen); @@ -58,9 +50,9 @@ export const NotificationsDropdown = () => {
          {notifications.length > 0 ? ( - notifications.map((notification, index) => ( + notifications.map((notification) => (

          @@ -77,7 +69,6 @@ export const NotificationsDropdown = () => {

          )}
          -
          )}
          diff --git a/frontend/src/hooks/useNotificationsSocket.ts b/frontend/src/hooks/useNotificationsSocket.ts new file mode 100644 index 0000000..b637c77 --- /dev/null +++ b/frontend/src/hooks/useNotificationsSocket.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { addNotification } from "../slices/notifications"; + + +export const useNotificationsSocket = (socket: any) => { + const dispatch = useDispatch(); + + useEffect(() => { + if (!socket) return; + const handleNewNotification = (data: { body: string }) => { + dispatch( + addNotification({ + id: crypto.randomUUID(), + body: data.body, + }) + ); + }; + + socket.on("on_notification", handleNewNotification); + + return () => { + socket.off("on_notification", handleNewNotification); + }; + }, [dispatch, socket]); +}; \ No newline at end of file diff --git a/frontend/src/slices/notifications.ts b/frontend/src/slices/notifications.ts new file mode 100644 index 0000000..574a03d --- /dev/null +++ b/frontend/src/slices/notifications.ts @@ -0,0 +1,31 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export interface AppNotification { + id: string; + body: string; + isRead?: boolean; +} + +interface NotificationsState { + items: AppNotification[]; +} + +const initialState: NotificationsState = { + items: [], +}; + +const notificationsSlice = createSlice({ + name: "notifications", + initialState, + reducers: { + addNotification: (state, action: PayloadAction) => { + state.items.unshift(action.payload); + }, + clearNotifications: (state) => { + state.items = []; + }, + }, +}); + +export const { addNotification, clearNotifications } = notificationsSlice.actions; +export default notificationsSlice.reducer; \ No newline at end of file diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 13cb770..b3ab437 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,8 +1,12 @@ import { configureStore } from '@reduxjs/toolkit' import { userSlice } from './slices/user'; +import notificationsReducer from './slices/notifications'; export const store = configureStore({ - reducer: userSlice.reducer, + reducer: { + user: userSlice.reducer, + notifications: notificationsReducer, + }, }); export type RootState = ReturnType; From 8a1e57edd90f2e0a14e0154ba791aa74c0207c73 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Thu, 9 Apr 2026 09:28:48 +0300 Subject: [PATCH 131/369] fix: fix notifications.ts --- frontend/src/slices/notifications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/slices/notifications.ts b/frontend/src/slices/notifications.ts index 574a03d..73387a6 100644 --- a/frontend/src/slices/notifications.ts +++ b/frontend/src/slices/notifications.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; export interface AppNotification { id: string; From 8b7b927a494c4f5a6971ab40df2b5b5334b94304 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Thu, 9 Apr 2026 09:50:44 +0300 Subject: [PATCH 132/369] fix(Auth): Fix profile page and dropMenu for profile --- frontend/src/components/Header.tsx | 11 +++++------ frontend/src/pages/Profile/Profile.tsx | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 26ec5be..a5d0d50 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -13,7 +13,7 @@ export const Header = () => { { path: "/contact", label: "Контакти" }, ]; - const user = useSelector((s: RootState) => s.user); + const user = useSelector((s: RootState) => s.user.user); return (
          @@ -50,16 +50,15 @@ export const Header = () => { ))} - {user ? ( + {user?.uid ? ( +
          ) : ( - - Увійти - - )} + Увійти + )}
        ); diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 3afcea7..333cf50 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -7,7 +7,7 @@ import { useMutation } from "@tanstack/react-query"; import { setUser } from "@/slices/user"; const Profile = () => { - const user = useSelector((s: RootState) => s.user); + const user = useSelector((s: RootState) => s.user.user); const deleteUserMutation = useMutation({ mutationKey: ["delete user"], mutationFn: async () => { From 03d17e6d61c0700bace45ab034aec993ca145e58 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Thu, 9 Apr 2026 21:22:08 +0300 Subject: [PATCH 133/369] feat(TOurnamentPage): Add changed data for deadlines --- frontend/src/pages/Home/Home.tsx | 2 +- .../pages/TournamentPage/TournamentPage.tsx | 68 +++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 32cdbf3..455e148 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -45,7 +45,7 @@ export const Home = () => { }, ]} mascot={{ - circularText: "★ ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ", + circularText: "★ЗНАЙДИ КОМАНДУ ★ ПРОЯВИ СЕБЕ ", lottieSrc: "/star.json", buttonText: "Долучитись", buttonLink: "/tournaments", diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx index f0f2547..9514c7e 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -1,9 +1,8 @@ -import { useState, useRef, useEffect, type ReactNode } from "react"; +import { useState, useRef, useEffect, useMemo, type ReactNode } from "react"; import { useParams } from "react-router-dom"; import apiClient from "@/api/client"; import { Hero } from "../../components/Hero"; -// --- ТИПІЗАЦІЯ --- interface TournamentData { title: string; description: string; @@ -23,7 +22,6 @@ const TABS = [ type TabId = (typeof TABS)[number]["id"]; type TourneyStatus = "registration" | "active" | "waiting" | "finished"; -// --- ІКОНКИ --- const RegistrationIcon = () => ( ); @@ -75,6 +73,19 @@ const STATUS_CONFIG: Record { + const now = new Date(); + const diffMs = targetDate.getTime() - now.getTime(); + + if (diffMs <= 0) return "0 годин"; + + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) return `${diffDays} днів`; + return `${diffHours} годин`; +}; + export const TournamentPage = () => { const { id } = useParams<{ id: string }>(); @@ -110,7 +121,54 @@ export const TournamentPage = () => { fetchTournament(); }, [id]); - const currentStatus: TourneyStatus = "active"; + const { currentStatus, deadlineValue, deadlineLabel } = useMemo(() => { + if (!tournament) { + return { currentStatus: "waiting" as TourneyStatus, deadlineValue: "...", deadlineLabel: "Завантаження" }; + } + + const now = new Date(); + const regStart = new Date(tournament.reg_start); + const regEnd = new Date(tournament.reg_end); + const eventStart = new Date(tournament.start_date); + + const eventEnd = new Date(eventStart.getTime() + 48 * 60 * 60 * 1000); + + if (now < regStart) { + return { + currentStatus: "waiting" as TourneyStatus, + deadlineValue: getTimeLeftInfo(regStart), + deadlineLabel: "До початку реєстрації" + }; + } + if (now >= regStart && now <= regEnd) { + return { + currentStatus: "registration" as TourneyStatus, + deadlineValue: getTimeLeftInfo(regEnd), + deadlineLabel: "До кінця реєстрації" + }; + } + if (now > regEnd && now < eventStart) { + return { + currentStatus: "waiting" as TourneyStatus, + deadlineValue: getTimeLeftInfo(eventStart), + deadlineLabel: "До старту турніру" + }; + } + if (now >= eventStart && now <= eventEnd) { + return { + currentStatus: "active" as TourneyStatus, + deadlineValue: getTimeLeftInfo(eventEnd), + deadlineLabel: "До здачі роботи" + }; + } + + return { + currentStatus: "finished" as TourneyStatus, + deadlineValue: "Завершено", + deadlineLabel: "Турнір" + }; + }, [tournament]); + const statusInfo = STATUS_CONFIG[currentStatus]; if (isLoading) { @@ -165,7 +223,7 @@ export const TournamentPage = () => {
        - +
        From 2445d06e1efa87e58ce57de952622efac22ae670 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Thu, 9 Apr 2026 21:49:33 +0300 Subject: [PATCH 134/369] feat(MainPage): Made adaptive for main page. Also fotter and header --- frontend/src/components/Footer.tsx | 41 ++++------- frontend/src/components/Header.tsx | 68 ++++++++++++++++--- frontend/src/components/Hero.tsx | 44 ++++++------ frontend/src/pages/Home/Home.tsx | 16 ++--- .../Home/components/TournamentSlider.tsx | 35 +++++----- 5 files changed, 118 insertions(+), 86 deletions(-) diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 1ef9499..d1a8cdb 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -3,48 +3,39 @@ import { Link } from "react-router-dom"; export const Footer = () => { return (
        -
        +
        -
        -
        +
        +
        UGalaxy x Star for Life -

        +

        Місце, де народжуються найкращі ідеї. Твори, навчайся, перемагай.

        -

        +

        Платформа

        • - + Всі турніри
        • - + Правила
        • - + FAQ
        • @@ -52,23 +43,17 @@ export const Footer = () => {
        -

        +

        Інформація

        • - + Про нас
        • - + Контакти
        • @@ -76,7 +61,7 @@ export const Footer = () => {
        -
        +

        © 2026 UGalaxy x Star for Life. Всі права захищено.

        diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index a5d0d50..c80b284 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Link, NavLink } from "react-router-dom"; import { useSelector } from "react-redux"; import { type RootState } from "../store"; @@ -6,6 +7,8 @@ import { ProfileDropdown } from "./ProfileDropdown"; import { NotificationsDropdown } from "./NotificationsDropdown"; export const Header = () => { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const navItems = [ { path: "/tournaments", label: "Турніри" }, { path: "/aboutUs", label: "Про нас" }, @@ -17,15 +20,15 @@ export const Header = () => { return (
        -
        +
        UGalaxy x Star for Life -
        + {isMobileMenuOpen && ( +
        + {navItems.map((item) => ( + setIsMobileMenuOpen(false)} + className={({ isActive }) => + `block font-semibold text-[18px] py-2 transition-colors duration-300 ${ + isActive ? "text-accent" : "text-white hover:text-accent" + }` + } + > + {item.label} + + ))} + {!user?.uid && ( + setIsMobileMenuOpen(false)} + className="btn btn-outline py-2 px-5 mt-2 text-center sm:hidden" + > + Увійти + + )} +
        + )}
        ); }; \ No newline at end of file diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 23202a0..334f79f 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -30,8 +30,8 @@ export const Hero = ({ mascot, }: HeroProps) => { return ( -
        -
        +
        +
        {bgText} ★ {bgText} ★  @@ -49,18 +49,18 @@ export const Hero = ({
        ))} -
        -

        +
        +

        {title}

        -

        +

        {description}

        - + {mascot && ( -
        +
        - +
        + +
        {mascot.buttonText}
        )} - +

        ); -}; +}; \ No newline at end of file diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 455e148..47b4769 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -54,27 +54,27 @@ export const Home = () => { -
        -
        +
        +
        -
        - +
        + Нові можливості -

        +

        Хочеш більше впливу на платформі?

        -

        +

        Подай заявку на отримання нової ролі та розблокуй додатковий функціонал для себе та своєї команди.

        -
        +
        Отримати роль diff --git a/frontend/src/pages/Home/components/TournamentSlider.tsx b/frontend/src/pages/Home/components/TournamentSlider.tsx index a5470a5..3875afe 100644 --- a/frontend/src/pages/Home/components/TournamentSlider.tsx +++ b/frontend/src/pages/Home/components/TournamentSlider.tsx @@ -8,7 +8,6 @@ export const TournamentSlider = () => { const progressBarRef = useRef(null); const navigate = useNavigate(); - const isPaused = useRef(false); const progressRef = useRef(0); const lastTimeRef = useRef(0); @@ -74,14 +73,14 @@ export const TournamentSlider = () => { }, [handleNext]); return ( -
        -
        -
        -

        +
        +
        +
        +

        Знайди свій турнір

        (isPaused.current = true)} onMouseLeave={() => (isPaused.current = false)} + onTouchStart={() => (isPaused.current = true)} + onTouchEnd={() => (isPaused.current = false)} >
        {TOURNAMENTS_DATA.map((card) => (
        ))} -
        +
        -
        -
        +
        +
        {
        ); -}; +}; \ No newline at end of file From 2441d63a66bc52ba836ca91ed9ebefc99c2af1e8 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Fri, 10 Apr 2026 10:02:11 +0300 Subject: [PATCH 135/369] feat(tournament model): add min and max people in team fields, rename max_team to max_teams --- backend/alembic/versions/3c3dbf9eab90_.py | 42 + backend/app/models/tournament.py | 4 +- frontend/package-lock.json | 2409 +++++++++++++++------ 3 files changed, 1783 insertions(+), 672 deletions(-) create mode 100644 backend/alembic/versions/3c3dbf9eab90_.py diff --git a/backend/alembic/versions/3c3dbf9eab90_.py b/backend/alembic/versions/3c3dbf9eab90_.py new file mode 100644 index 0000000..2df7ccc --- /dev/null +++ b/backend/alembic/versions/3c3dbf9eab90_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 3c3dbf9eab90 +Revises: 8bb9c2b1a990 +Create Date: 2026-04-10 10:01:36.288160 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3c3dbf9eab90' +down_revision: Union[str, Sequence[str], None] = '8bb9c2b1a990' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.add_column(sa.Column('min_people_in_team', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('max_people_in_team', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('max_teams', sa.Integer(), nullable=False)) + batch_op.drop_column('max_team') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tournaments', schema=None) as batch_op: + batch_op.add_column(sa.Column('max_team', sa.INTEGER(), autoincrement=False, nullable=False)) + batch_op.drop_column('max_teams') + batch_op.drop_column('max_people_in_team') + batch_op.drop_column('min_people_in_team') + + # ### end Alembic commands ### diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index 1b14e67..ae96247 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -15,7 +15,9 @@ class Tournament(Base, PKMixin): start_date: Mapped[datetime] reg_start: Mapped[datetime] reg_end: Mapped[datetime] - max_team: Mapped[int] + min_people_in_team: Mapped[int] + max_people_in_team: Mapped[int] + max_teams: Mapped[int] active_task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"), nullable=True) status_id: Mapped[int] = mapped_column(ForeignKey("tournament_status_options.id")) creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fc44d65..d34d421 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -566,626 +566,674 @@ "node": ">=20.19.0" } }, - "node_modules/@esbuild/linux-x64": { + "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ - "x64" + "ppc64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@firebase-oss/ui-core": { - "version": "7.0.2-beta", - "resolved": "https://registry.npmjs.org/@firebase-oss/ui-core/-/ui-core-7.0.2-beta.tgz", - "integrity": "sha512-pm6u75QU/0BaDC7Z9qbRw/hHrUXUMXLzOgtvWHeH4+EtuJRMiC6JI+g/O2Pmenz4o5rKhSZfC3d58+6j0g5lOA==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "dependencies": { - "@firebase-oss/ui-translations": "7.0.2-beta", - "@nanostores/deepmap": "^0.0.1", - "libphonenumber-js": "^1.12.23", - "nanostores": "^1.1.0", - "qrcode-generator": "^2.0.4", - "zod": "4.1.12" - }, - "peerDependencies": { - "firebase": "^11 || ^12" - } - }, - "node_modules/@firebase-oss/ui-core/node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@firebase-oss/ui-react": { - "version": "7.0.2-beta", - "resolved": "https://registry.npmjs.org/@firebase-oss/ui-react/-/ui-react-7.0.2-beta.tgz", - "integrity": "sha512-Kuxcu7nEe5XVBLxSKJfRuaU7VnbGKgMl0X4m3VKDfLqfCoXyj8BwhynXeX3r+JH7sQXe4DflgkPUvnekVNEyTg==", - "dependencies": { - "@firebase-oss/ui-core": "7.0.2-beta", - "@firebase-oss/ui-styles": "7.0.2-beta", - "@nanostores/react": "^1.0.0", - "@radix-ui/react-slot": "^1.2.3", - "@tanstack/react-form": "1.20.0", - "clsx": "^2.1.1", - "tailwind-merge": "^3.0.1", - "zod": "4.1.12" - }, - "peerDependencies": { - "firebase": "^11 || ^12", - "react": "^19", - "react-dom": "^19" + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@firebase-oss/ui-react/node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@firebase-oss/ui-styles": { - "version": "7.0.2-beta", - "resolved": "https://registry.npmjs.org/@firebase-oss/ui-styles/-/ui-styles-7.0.2-beta.tgz", - "integrity": "sha512-o3MS/fe7072FlhpjCf0GAoY4Q1UravGYuHEDrauqgjjWcNJANGx9w8zXr0eNPJyRdneawZLd9iIQt/Lwu33mxQ==", - "dependencies": { - "cva": "1.0.0-beta.4" + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@firebase-oss/ui-translations": { - "version": "7.0.2-beta", - "resolved": "https://registry.npmjs.org/@firebase-oss/ui-translations/-/ui-translations-7.0.2-beta.tgz", - "integrity": "sha512-DFMcCJZv6qlaxPbkd3tUjn1ngT7i9Z31T2K9S1OawtlmWlwy7A7Lxon6g1OFSzc3mmUssoC7v0/muB8A3g2vTg==" + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@firebase/ai": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.9.0.tgz", - "integrity": "sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" + "node": ">=18" } }, - "node_modules/@firebase/analytics": { - "version": "0.10.20", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.20.tgz", - "integrity": "sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@firebase/analytics-compat": { - "version": "0.2.26", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz", - "integrity": "sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/analytics": "0.10.20", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.7.1", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@firebase/analytics-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", - "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", - "license": "Apache-2.0" + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@firebase/app": { - "version": "0.14.9", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", - "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=20.0.0" + "node": ">=18" } }, - "node_modules/@firebase/app-check": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.1.tgz", - "integrity": "sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==", - "license": "Apache-2.0", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=20.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "@firebase/app": "0.x" + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@firebase/app-check-compat": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz", - "integrity": "sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check": "0.11.1", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, "engines": { - "node": ">=20.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependencies": { - "@firebase/app-compat": "0.x" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-check-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", - "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-compat": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", - "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app": "0.14.9", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@firebase/auth": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.1.tgz", - "integrity": "sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==", + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@react-native-async-storage/async-storage": "^2.2.0" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@firebase/auth-compat": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.3.tgz", - "integrity": "sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@firebase/auth": "1.12.1", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.7.1", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" + "@eslint/core": "^0.17.0" }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth-types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", - "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@firebase/component": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", - "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@firebase/data-connect": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.4.0.tgz", - "integrity": "sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==", - "license": "Apache-2.0", + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, - "peerDependencies": { - "@firebase/app": "0.x" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@firebase/database": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", - "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@firebase/database-compat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", - "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/database": "1.1.1", - "@firebase/database-types": "1.0.17", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@firebase/database-types": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", - "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, "license": "Apache-2.0", - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.14.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@firebase/firestore": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.12.0.tgz", - "integrity": "sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "@firebase/webchannel-wrapper": "1.0.5", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@firebase/firestore-compat": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz", - "integrity": "sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/firestore": "4.12.0", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@firebase/app-compat": "0.x" + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@firebase/firestore-types": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", - "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", - "license": "Apache-2.0", + "node_modules/@firebase-oss/ui-core": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-core/-/ui-core-7.0.2-beta.tgz", + "integrity": "sha512-pm6u75QU/0BaDC7Z9qbRw/hHrUXUMXLzOgtvWHeH4+EtuJRMiC6JI+g/O2Pmenz4o5rKhSZfC3d58+6j0g5lOA==", + "license": "MIT", + "dependencies": { + "@firebase-oss/ui-translations": "7.0.2-beta", + "@nanostores/deepmap": "^0.0.1", + "libphonenumber-js": "^1.12.23", + "nanostores": "^1.1.0", + "qrcode-generator": "^2.0.4", + "zod": "4.1.12" + }, "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" + "firebase": "^11 || ^12" } }, - "node_modules/@firebase/functions": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.2.tgz", - "integrity": "sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==", - "license": "Apache-2.0", + "node_modules/@firebase-oss/ui-core/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@firebase-oss/ui-react": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-react/-/ui-react-7.0.2-beta.tgz", + "integrity": "sha512-Kuxcu7nEe5XVBLxSKJfRuaU7VnbGKgMl0X4m3VKDfLqfCoXyj8BwhynXeX3r+JH7sQXe4DflgkPUvnekVNEyTg==", "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.1", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" + "@firebase-oss/ui-core": "7.0.2-beta", + "@firebase-oss/ui-styles": "7.0.2-beta", + "@nanostores/react": "^1.0.0", + "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-form": "1.20.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.0.1", + "zod": "4.1.12" }, "peerDependencies": { - "@firebase/app": "0.x" + "firebase": "^11 || ^12", + "react": "^19", + "react-dom": "^19" } }, - "node_modules/@firebase/functions-compat": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.2.tgz", - "integrity": "sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==", + "node_modules/@firebase-oss/ui-react/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@firebase-oss/ui-styles": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-styles/-/ui-styles-7.0.2-beta.tgz", + "integrity": "sha512-o3MS/fe7072FlhpjCf0GAoY4Q1UravGYuHEDrauqgjjWcNJANGx9w8zXr0eNPJyRdneawZLd9iIQt/Lwu33mxQ==", + "dependencies": { + "cva": "1.0.0-beta.4" + } + }, + "node_modules/@firebase-oss/ui-translations": { + "version": "7.0.2-beta", + "resolved": "https://registry.npmjs.org/@firebase-oss/ui-translations/-/ui-translations-7.0.2-beta.tgz", + "integrity": "sha512-DFMcCJZv6qlaxPbkd3tUjn1ngT7i9Z31T2K9S1OawtlmWlwy7A7Lxon6g1OFSzc3mmUssoC7v0/muB8A3g2vTg==" + }, + "node_modules/@firebase/ai": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.9.0.tgz", + "integrity": "sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==", "license": "Apache-2.0", "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", "@firebase/component": "0.7.1", - "@firebase/functions": "0.13.2", - "@firebase/functions-types": "0.6.3", + "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", "tslib": "^2.1.0" }, @@ -1193,39 +1241,35 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@firebase/app-compat": "0.x" + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" } }, - "node_modules/@firebase/functions-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", - "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/installations": { - "version": "0.6.20", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.20.tgz", - "integrity": "sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==", + "node_modules/@firebase/analytics": { + "version": "0.10.20", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.20.tgz", + "integrity": "sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", - "idb": "7.1.1", "tslib": "^2.1.0" }, "peerDependencies": { "@firebase/app": "0.x" } }, - "node_modules/@firebase/installations-compat": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.20.tgz", - "integrity": "sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==", + "node_modules/@firebase/analytics-compat": { + "version": "0.2.26", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz", + "integrity": "sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==", "license": "Apache-2.0", "dependencies": { + "@firebase/analytics": "0.10.20", + "@firebase/analytics-types": "0.8.3", "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/installations-types": "0.5.3", "@firebase/util": "1.14.0", "tslib": "^2.1.0" }, @@ -1233,113 +1277,183 @@ "@firebase/app-compat": "0.x" } }, - "node_modules/@firebase/installations-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", - "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x" - } + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" }, - "node_modules/@firebase/logger": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", - "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "node_modules/@firebase/app": { + "version": "0.14.9", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", + "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", "license": "Apache-2.0", + "peer": true, "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "idb": "7.1.1", "tslib": "^2.1.0" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@firebase/messaging": { - "version": "0.12.24", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.24.tgz", - "integrity": "sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==", + "node_modules/@firebase/app-check": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.1.tgz", + "integrity": "sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/messaging-interop-types": "0.2.3", + "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", - "idb": "7.1.1", "tslib": "^2.1.0" }, + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { "@firebase/app": "0.x" } }, - "node_modules/@firebase/messaging-compat": { - "version": "0.2.24", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz", - "integrity": "sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==", + "node_modules/@firebase/app-check-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz", + "integrity": "sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==", "license": "Apache-2.0", "dependencies": { + "@firebase/app-check": "0.11.1", + "@firebase/app-check-types": "0.5.3", "@firebase/component": "0.7.1", - "@firebase/messaging": "0.12.24", + "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", "tslib": "^2.1.0" }, + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { "@firebase/app-compat": "0.x" } }, - "node_modules/@firebase/messaging-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", - "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", "license": "Apache-2.0" }, - "node_modules/@firebase/performance": { - "version": "0.7.10", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.10.tgz", - "integrity": "sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==", + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", + "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", "license": "Apache-2.0", + "peer": true, "dependencies": { + "@firebase/app": "0.14.9", "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" + "tslib": "^2.1.0" }, - "peerDependencies": { - "@firebase/app": "0.x" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@firebase/performance-compat": { - "version": "0.2.23", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.23.tgz", - "integrity": "sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==", + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/auth": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.1.tgz", + "integrity": "sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", - "@firebase/performance": "0.7.10", - "@firebase/performance-types": "0.2.3", "@firebase/util": "1.14.0", "tslib": "^2.1.0" }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.3.tgz", + "integrity": "sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.12.1", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { "@firebase/app-compat": "0.x" } }, - "node_modules/@firebase/performance-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", - "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", "license": "Apache-2.0" }, - "node_modules/@firebase/remote-config": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.1.tgz", - "integrity": "sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==", + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", + "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.4.0.tgz", + "integrity": "sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==", "license": "Apache-2.0", "dependencies": { + "@firebase/auth-interop-types": "0.2.4", "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", "tslib": "^2.1.0" @@ -1348,37 +1462,63 @@ "@firebase/app": "0.x" } }, - "node_modules/@firebase/remote-config-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz", - "integrity": "sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==", + "node_modules/@firebase/database": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", + "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", "license": "Apache-2.0", "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", - "@firebase/remote-config": "0.8.1", - "@firebase/remote-config-types": "0.5.0", "@firebase/util": "1.14.0", + "faye-websocket": "0.11.4", "tslib": "^2.1.0" }, - "peerDependencies": { - "@firebase/app-compat": "0.x" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@firebase/remote-config-types": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", - "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", - "license": "Apache-2.0" + "node_modules/@firebase/database-compat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", + "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/database": "1.1.1", + "@firebase/database-types": "1.0.17", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } }, - "node_modules/@firebase/storage": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.1.tgz", - "integrity": "sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==", + "node_modules/@firebase/database-types": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", + "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.14.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.12.0.tgz", + "integrity": "sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", "@firebase/util": "1.14.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", "tslib": "^2.1.0" }, "engines": { @@ -1388,15 +1528,15 @@ "@firebase/app": "0.x" } }, - "node_modules/@firebase/storage-compat": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.1.tgz", - "integrity": "sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==", + "node_modules/@firebase/firestore-compat": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz", + "integrity": "sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.1", - "@firebase/storage": "0.14.1", - "@firebase/storage-types": "0.8.3", + "@firebase/firestore": "4.12.0", + "@firebase/firestore-types": "3.0.3", "@firebase/util": "1.14.0", "tslib": "^2.1.0" }, @@ -1407,91 +1547,351 @@ "@firebase/app-compat": "0.x" } }, - "node_modules/@firebase/storage-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", - "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", "license": "Apache-2.0", "peerDependencies": { "@firebase/app-types": "0.x", "@firebase/util": "1.x" } }, - "node_modules/@firebase/util": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", - "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", - "hasInstallScript": true, + "node_modules/@firebase/functions": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.2.tgz", + "integrity": "sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.14.0", "tslib": "^2.1.0" }, "engines": { "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" } }, - "node_modules/@firebase/webchannel-wrapper": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", - "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", - "license": "Apache-2.0" - }, - "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "node_modules/@firebase/functions-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.2.tgz", + "integrity": "sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==", "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" + "@firebase/component": "0.7.1", + "@firebase/functions": "0.13.2", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" }, "engines": { - "node": "^8.13.0 || >=10.10.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" } }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.20", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.20.tgz", + "integrity": "sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==", "license": "Apache-2.0", "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "idb": "7.1.1", + "tslib": "^2.1.0" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "@firebase/app": "0.x" } }, - "node_modules/@hookform/resolvers": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", - "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", - "license": "MIT", + "node_modules/@firebase/installations-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.20.tgz", + "integrity": "sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==", + "license": "Apache-2.0", "dependencies": { - "@standard-schema/utils": "^0.3.0" + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" }, "peerDependencies": { - "react-hook-form": "^7.55.0" + "@firebase/app-compat": "0.x" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" + "peerDependencies": { + "@firebase/app-types": "0.x" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.24", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.24.tgz", + "integrity": "sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.14.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz", + "integrity": "sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/messaging": "0.12.24", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.10.tgz", + "integrity": "sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.23.tgz", + "integrity": "sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.10", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.1.tgz", + "integrity": "sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/installations": "0.6.20", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz", + "integrity": "sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.1", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.1.tgz", + "integrity": "sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.1.tgz", + "integrity": "sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/storage": "0.14.1", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", + "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, @@ -1750,80 +2150,491 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-linux-x64-gnu": { + "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "freebsd" + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", "cpu": [ - "x64" + "arm" ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@tailwindcss/node": { + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide": { + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { @@ -1858,6 +2669,67 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", @@ -3705,6 +4577,20 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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", @@ -4203,6 +5089,146 @@ "lightningcss-win32-x64-msvc": "1.31.1" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "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.31.1", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", @@ -4243,6 +5269,46 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4738,6 +5804,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, From 6c8fa8a57ec9bbb77adaa0e6e4dd67318042d3b5 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Fri, 10 Apr 2026 10:18:56 +0300 Subject: [PATCH 136/369] feat(user model): add telegram, github and discord fields --- backend/alembic/versions/2e9ac7d1edd6_.py | 40 +++++++++++++++++++++++ backend/app/models/user.py | 3 ++ backend/app/schemas/user.py | 6 ++++ frontend/src/pages/Profile/Profile.css | 6 ++-- frontend/src/pages/Profile/Profile.tsx | 8 ++--- frontend/src/slices/user.ts | 3 ++ 6 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/2e9ac7d1edd6_.py diff --git a/backend/alembic/versions/2e9ac7d1edd6_.py b/backend/alembic/versions/2e9ac7d1edd6_.py new file mode 100644 index 0000000..a375cd5 --- /dev/null +++ b/backend/alembic/versions/2e9ac7d1edd6_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 2e9ac7d1edd6 +Revises: 3c3dbf9eab90 +Create Date: 2026-04-10 10:07:45.093103 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2e9ac7d1edd6' +down_revision: Union[str, Sequence[str], None] = '3c3dbf9eab90' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('telegram', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('github', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('discord', sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('discord') + batch_op.drop_column('github') + batch_op.drop_column('telegram') + + # ### end Alembic commands ### diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 64b850c..9d633c9 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -20,6 +20,9 @@ class User(Base, PKMixin): full_name: Mapped[str] = mapped_column(nullable=False) email: Mapped[str] = mapped_column(nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + telegram: Mapped[str] = mapped_column(nullable=True) + github: Mapped[str] = mapped_column(nullable=True) + discord: Mapped[str] = mapped_column(nullable=True) roles: Mapped[list["Role"]] = relationship( secondary=user_roles, back_populates="users", lazy="selectin", diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 4568076..61cee36 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -20,12 +20,18 @@ class UserCreate(UserBase): class UserUpdate(UserBase): full_name: str | None = None email: str | None = None + telegram: str | None = None + github: str | None = None + discord: str | None = None class UserPublic(UserBase): id: int email: EmailStr firebase_uid: str roles: list[RolePublic] + telegram: str | None + github: str | None + discord: str | None class UserModel(UserBase): email: EmailStr = Field(..., description="User email") diff --git a/frontend/src/pages/Profile/Profile.css b/frontend/src/pages/Profile/Profile.css index 33f33c8..1db9f64 100644 --- a/frontend/src/pages/Profile/Profile.css +++ b/frontend/src/pages/Profile/Profile.css @@ -146,7 +146,7 @@ body { border: 1px solid var(--border-color); } -.contact-chip a { +.contact-chip .contact-value { text-decoration: none; color: #111827; font-weight: 500; @@ -157,10 +157,10 @@ body { } .blue-chip { background-color: #eff6ff; border-color: #bfdbfe; } -.blue-chip a { color: #2563eb; } +.blue-chip .contact-value { color: #2563eb; } .purple-chip { background-color: #f5f3ff; border-color: #ddd6fe; } -.purple-chip a { color: #7c3aed; } +.purple-chip .contact-value { color: #7c3aed; } /* Нижня сітка */ .content-grid { diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 333cf50..a523424 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -65,19 +65,19 @@ const Profile = () => {
        Email: - hacker777@example.com + hacker777@example.com
        Telegram: - @hacker777 + {user.telegram ?? 'Відсутній'}
        GitHub: - hacker777 + {user.github ?? 'Відсутній'}
        Discord: - @hacker777 + {user.discord ?? 'Відсутній'}

        diff --git a/frontend/src/slices/user.ts b/frontend/src/slices/user.ts index 9c190bf..95ca8a7 100644 --- a/frontend/src/slices/user.ts +++ b/frontend/src/slices/user.ts @@ -14,6 +14,9 @@ export interface ApiUserData { notifications: object[]; role_requests: object[]; created_tournaments: object[]; + telegram?: string; + github?: string; + discord?: string; } export interface UserData extends ApiUserData, FirebaseUserData { }; From 193c02cc710a376e7c0d44b03140214f58115fec Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Fri, 10 Apr 2026 10:28:33 +0300 Subject: [PATCH 137/369] fix(auth): user is not redirected upon signing out and visiting a protected route --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index db4c67a..d83a3ae 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -8,9 +8,9 @@ interface ProtectedRouteProps { } const ProtectedRoute = ({ children }: ProtectedRouteProps) => { - const user = useSelector((s: RootState) => s.user); + const user = useSelector((s: RootState) => s.user.user); if (user === undefined) return
        Loading...
        ; - if (user === null) return ; + if (user === null) return ; return children ? children : ; } From bc5304e617e203182271694a0a73ed71872198ae Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Fri, 10 Apr 2026 13:32:37 +0300 Subject: [PATCH 138/369] chore(Header): move profile menu to burger menu --- frontend/src/components/Header.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c80b284..8b90391 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -57,7 +57,9 @@ export const Header = () => { {user?.uid ? (
        - +
        + +
        ) : ( @@ -79,6 +81,7 @@ export const Header = () => {
    + {isMobileMenuOpen && (
    {navItems.map((item) => ( @@ -95,7 +98,12 @@ export const Header = () => { {item.label} ))} - {!user?.uid && ( + + {user?.uid ? ( +
    + +
    + ) : ( setIsMobileMenuOpen(false)} From 10591b7b2e9d633baebb18a125e4d8e7abaa67bf Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Fri, 10 Apr 2026 13:33:34 +0300 Subject: [PATCH 139/369] feat(error): Add error modal --- .../pages/TournamentPage/TournamentPage.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/TournamentPage/TournamentPage.tsx b/frontend/src/pages/TournamentPage/TournamentPage.tsx index 9514c7e..07710ab 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.tsx @@ -184,19 +184,29 @@ export const TournamentPage = () => { if (error || !tournament) { return ( -
    -
    -
    +
    +
    +
    -

    Ой, халепа!

    -

    {error || "Турнір не знайдено"}

    +

    + Ой, халепа! +

    +

    + Проблемки. Турнір трохи загубився в мережі або щось пішло не так. Але не хвилюйтесь, ми вже намагаємося його знайти! +

    +
    +

    + {error || "Помилка 500: Турнір не знайдено"} +

    +
    +
    ); From 6f76d42c5dbfe51fcbd02b162ca787e34de149bf Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Fri, 10 Apr 2026 20:48:20 +0300 Subject: [PATCH 140/369] feat(edit profile): Add modal for editing user data --- backend/alembic/versions/2e9ac7d1edd6_.py | 40 ------ backend/alembic/versions/3c3dbf9eab90_.py | 42 ------ ...x.py => 765a01d9d040_initial_migration.py} | 15 +- frontend/src/api/requests/updateUser.ts | 24 ++++ .../src/pages/Profile/EditProfileModal.tsx | 123 +++++++++++++++++ frontend/src/pages/Profile/Profile.css | 130 ++++++++++++++++++ frontend/src/pages/Profile/Profile.tsx | 12 +- 7 files changed, 297 insertions(+), 89 deletions(-) delete mode 100644 backend/alembic/versions/2e9ac7d1edd6_.py delete mode 100644 backend/alembic/versions/3c3dbf9eab90_.py rename backend/alembic/versions/{8bb9c2b1a990_my_local_fix.py => 765a01d9d040_initial_migration.py} (95%) create mode 100644 frontend/src/api/requests/updateUser.ts create mode 100644 frontend/src/pages/Profile/EditProfileModal.tsx diff --git a/backend/alembic/versions/2e9ac7d1edd6_.py b/backend/alembic/versions/2e9ac7d1edd6_.py deleted file mode 100644 index a375cd5..0000000 --- a/backend/alembic/versions/2e9ac7d1edd6_.py +++ /dev/null @@ -1,40 +0,0 @@ -"""empty message - -Revision ID: 2e9ac7d1edd6 -Revises: 3c3dbf9eab90 -Create Date: 2026-04-10 10:07:45.093103 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '2e9ac7d1edd6' -down_revision: Union[str, Sequence[str], None] = '3c3dbf9eab90' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('telegram', sa.String(), nullable=True)) - batch_op.add_column(sa.Column('github', sa.String(), nullable=True)) - batch_op.add_column(sa.Column('discord', sa.String(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('discord') - batch_op.drop_column('github') - batch_op.drop_column('telegram') - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/3c3dbf9eab90_.py b/backend/alembic/versions/3c3dbf9eab90_.py deleted file mode 100644 index 2df7ccc..0000000 --- a/backend/alembic/versions/3c3dbf9eab90_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""empty message - -Revision ID: 3c3dbf9eab90 -Revises: 8bb9c2b1a990 -Create Date: 2026-04-10 10:01:36.288160 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '3c3dbf9eab90' -down_revision: Union[str, Sequence[str], None] = '8bb9c2b1a990' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.add_column(sa.Column('min_people_in_team', sa.Integer(), nullable=False)) - batch_op.add_column(sa.Column('max_people_in_team', sa.Integer(), nullable=False)) - batch_op.add_column(sa.Column('max_teams', sa.Integer(), nullable=False)) - batch_op.drop_column('max_team') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.add_column(sa.Column('max_team', sa.INTEGER(), autoincrement=False, nullable=False)) - batch_op.drop_column('max_teams') - batch_op.drop_column('max_people_in_team') - batch_op.drop_column('min_people_in_team') - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/8bb9c2b1a990_my_local_fix.py b/backend/alembic/versions/765a01d9d040_initial_migration.py similarity index 95% rename from backend/alembic/versions/8bb9c2b1a990_my_local_fix.py rename to backend/alembic/versions/765a01d9d040_initial_migration.py index b8d2bf8..6f1c9f7 100644 --- a/backend/alembic/versions/8bb9c2b1a990_my_local_fix.py +++ b/backend/alembic/versions/765a01d9d040_initial_migration.py @@ -1,8 +1,8 @@ -"""my_local_fix +"""Initial migration -Revision ID: 8bb9c2b1a990 +Revision ID: 765a01d9d040 Revises: -Create Date: 2026-04-04 16:45:51.775994 +Create Date: 2026-04-10 12:50:06.140924 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. -revision: str = '8bb9c2b1a990' +revision: str = '765a01d9d040' down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -72,6 +72,9 @@ def upgrade() -> None: sa.Column('full_name', sa.String(), nullable=False), sa.Column('email', sa.String(), nullable=False), sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('telegram', sa.String(), nullable=True), + sa.Column('github', sa.String(), nullable=True), + sa.Column('discord', sa.String(), nullable=True), sa.Column('id', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email'), @@ -141,7 +144,9 @@ def upgrade() -> None: sa.Column('start_date', sa.DateTime(), nullable=False), sa.Column('reg_start', sa.DateTime(), nullable=False), sa.Column('reg_end', sa.DateTime(), nullable=False), - sa.Column('max_team', sa.Integer(), nullable=False), + sa.Column('min_people_in_team', sa.Integer(), nullable=False), + sa.Column('max_people_in_team', sa.Integer(), nullable=False), + sa.Column('max_teams', sa.Integer(), nullable=False), sa.Column('active_task_id', sa.Integer(), nullable=True), sa.Column('status_id', sa.Integer(), nullable=False), sa.Column('creator_id', sa.Integer(), nullable=False), diff --git a/frontend/src/api/requests/updateUser.ts b/frontend/src/api/requests/updateUser.ts new file mode 100644 index 0000000..df7b79e --- /dev/null +++ b/frontend/src/api/requests/updateUser.ts @@ -0,0 +1,24 @@ +import { auth } from "../../firebase"; +import apiClient from "../client"; + +export const updateUser = async (userId: string, data: any) => { + try { + const currentUser = auth.currentUser; + + if (!currentUser) { + throw new Error("Користувач не авторизований у Firebase"); + } + + const token = await currentUser.getIdToken(); + const response = await apiClient.patch(`/users/${userId}/`, data, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data; + } catch (e) { + console.error(`Error occurred: ${e}`); + throw e + } +} \ No newline at end of file diff --git a/frontend/src/pages/Profile/EditProfileModal.tsx b/frontend/src/pages/Profile/EditProfileModal.tsx new file mode 100644 index 0000000..f5e406b --- /dev/null +++ b/frontend/src/pages/Profile/EditProfileModal.tsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { store } from "../../store"; +import { setUser } from "@/slices/user"; +import { updateUser } from "@/api/requests/updateUser"; + +interface EditProfileModalProps { + isOpen: boolean; + onClose: () => void; + currentUser: any; +} + +export const EditProfileModal: React.FC = ({ isOpen, onClose, currentUser }) => { + const [formData, setFormData] = useState({ + full_name: currentUser?.displayName || "", + email: currentUser?.email || "hacker777@example.com", + telegram: currentUser?.telegram || "", + github: currentUser?.github || "", + discord: currentUser?.discord || "", + }); + + const updateMutation = useMutation({ + // Використовуємо uid, або id як запасний варіант + mutationKey: ["update user", currentUser?.uid || currentUser?.id], + mutationFn: async (data: typeof formData) => { + const userId = currentUser.uid || currentUser.id; + if (!userId) throw new Error("ID користувача не знайдено!"); + + return await updateUser(userId, data); + }, + onSuccess: (variables) => { + store.dispatch( + setUser({ + ...currentUser, + displayName: variables.full_name, + ...variables + }) + ); + onClose(); + }, + onError: (e: any) => { + console.error("Помилка при оновленні профілю", e.message); + }, + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateMutation.mutate(formData); + }; + + if (!isOpen) return null; + + return ( +
    +
    e.stopPropagation()}> +
    +

    Редагувати профіль

    + +
    + +
    +
    + + +
    + +
    + + +
    + +
    +

    Додаткові контакти

    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Profile/Profile.css b/frontend/src/pages/Profile/Profile.css index 1db9f64..4fec563 100644 --- a/frontend/src/pages/Profile/Profile.css +++ b/frontend/src/pages/Profile/Profile.css @@ -246,6 +246,136 @@ body { .team-name { font-weight: 500; } +/* --- Стилі для Модалки Редагування --- */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(17, 24, 39, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.modal-card { + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + animation: modalFadeIn 0.3s ease; +} + +@keyframes modalFadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.modal-header h2 { + margin: 0; + font-size: 20px; + color: var(--text-main); +} + +.close-btn { + background: none; + border: none; + font-size: 28px; + color: var(--text-muted); + cursor: pointer; + line-height: 1; + padding: 0; +} + +.close-btn:hover { + color: var(--text-main); +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 14px; + font-weight: 500; + color: var(--text-main); +} + +.form-input { + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 15px; + background-color: #f9fafb; + transition: all 0.2s ease; + font-family: inherit; + color: var(--text-main) +} + +.form-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + background-color: #fff; + сolor: var(--text-main); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; +} + +.btn-secondary { + padding: 10px 20px; + background-color: white; + border: 1px solid var(--border-color); + border-radius: 8px; + font-weight: 500; + color: var(--text-main); + cursor: pointer; +} + +.btn-primary { + padding: 10px 20px; + background-color: var(--primary); + border: none; + border-radius: 8px; + font-weight: 500; + color: white; + cursor: pointer; +} + +.btn-primary:hover { background-color: #4f46e5; } +.btn-primary:disabled { opacity: 0.7; cursor: not-allowed; } + +/* Додатково: червона кнопка для видалення */ +.delete-btn { + background-color: #fee2e2; + color: #ef4444; + margin-left: 12px; +} +.delete-btn:hover { background-color: #fecaca; opacity: 1; } /* Адаптивність для мобільних пристроїв */ @media (max-width: 768px) { diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index a523424..7a3428f 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -5,9 +5,12 @@ import { store, type RootState } from "../../store"; import { deleteUser } from "@/api/requests"; import { useMutation } from "@tanstack/react-query"; import { setUser } from "@/slices/user"; +import { useState } from "react"; +import { EditProfileModal } from "./EditProfileModal"; const Profile = () => { const user = useSelector((s: RootState) => s.user.user); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); const deleteUserMutation = useMutation({ mutationKey: ["delete user"], mutationFn: async () => { @@ -52,7 +55,7 @@ const Profile = () => { Роль: Користувач
    - + @@ -65,7 +68,7 @@ const Profile = () => {
    Email: - hacker777@example.com + {user.email ?? 'Відсутній'}
    Telegram: @@ -148,6 +151,11 @@ const Profile = () => {
    + setIsEditModalOpen(false)} + currentUser={user} + />
    ); }; From 5a543820f7a33b9ed35c01e69d0008d5d184c4a9 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 10 Apr 2026 20:32:57 +0100 Subject: [PATCH 141/369] feat: add FSM for tasks --- backend/app/core/seeds/status.py | 24 +++++++- backend/app/main.py | 3 +- backend/app/models/task.py | 21 ++++--- backend/app/routes/tasks.py | 14 +++-- backend/app/routes/tournaments.py | 12 ++-- backend/app/schemas/__init__.py | 2 +- backend/app/schemas/task.py | 41 ++++++++------ backend/app/schemas/tournament.py | 2 +- backend/app/utils/fsm/__init__.py | 2 + backend/app/utils/fsm/task_status.py | 55 +++++++++++++++++++ .../{routes => fsm}/tournament_status.py | 0 backend/app/utils/routes/__init__.py | 1 - 12 files changed, 134 insertions(+), 43 deletions(-) create mode 100644 backend/app/utils/fsm/__init__.py create mode 100644 backend/app/utils/fsm/task_status.py rename backend/app/utils/{routes => fsm}/tournament_status.py (100%) diff --git a/backend/app/core/seeds/status.py b/backend/app/core/seeds/status.py index 72749fd..6a9808d 100644 --- a/backend/app/core/seeds/status.py +++ b/backend/app/core/seeds/status.py @@ -1,9 +1,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models import TournamentStatusOption +from app.models import TournamentStatusOption, TaskStatusOption -DEFAULT_STATUSES = { +DEFAULT_TOURNAMENT_STATUSES = { "draft": "Draft", "registration": "Registration", "running": "Running", @@ -11,9 +11,16 @@ "canceled": "Canceled", } +DEFAULT_TASK_STATUSES = { + "draft": "Draft", + "active": "Active", + "submission_closed": "SubmissionClosed", + "evaluated": "Evaluated", +} + async def init_tournament_statuses(session: AsyncSession): - for name, display in DEFAULT_STATUSES.items(): + for name, display in DEFAULT_TOURNAMENT_STATUSES.items(): statement = select(TournamentStatusOption).where( TournamentStatusOption.name == name ) @@ -22,3 +29,14 @@ async def init_tournament_statuses(session: AsyncSession): session.add(TournamentStatusOption(name=name, display_name=display)) await session.commit() + + +async def init_task_statuses(session: AsyncSession): + for name, display in DEFAULT_TASK_STATUSES.items(): + statement = select(TaskStatusOption).where(TaskStatusOption.name == name) + result = await session.execute(statement) + + if not result.scalar_one_or_none(): + session.add(TaskStatusOption(name=name, display_name=display)) + + await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index e0355cb..8e790bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ import app.routes.teams as teams import app.routes.team_members as team_members -from app.core.seeds.status import init_tournament_statuses +from app.core.seeds.status import init_tournament_statuses, init_task_statuses from app.db import AsyncSessionLocal @@ -17,6 +17,7 @@ async def lifespan(app: FastAPI): async with AsyncSessionLocal() as session: await init_tournament_statuses(session) + await init_task_statuses(session) yield diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 7dc8ae1..9bd2d8b 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -8,12 +8,12 @@ task_requirements = Table( "task_requirements", Base.metadata, + Column("task_id", ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), Column( "requirement_id", ForeignKey("task_requirement_options.name", ondelete="CASCADE"), primary_key=True, ), - Column("task_id", ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), ) @@ -21,17 +21,22 @@ class Task(Base, PKMixin): __tablename__ = "tasks" title: Mapped[str] - description: Mapped[str] + description: Mapped[str] = mapped_column(nullable=True) + start_time: Mapped[datetime] + end_time: Mapped[datetime] tournament_id: Mapped[int] = mapped_column( ForeignKey("tournaments.id", use_alter=True, name="fk_task_tournament") ) tournament: Mapped["Tournament"] = relationship( - "Tournament", back_populates="tasks", foreign_keys="Task.tournament_id", lazy="selectin" + "Tournament", + back_populates="tasks", + foreign_keys="Task.tournament_id", + lazy="selectin", ) - start_time: Mapped[datetime] - end_time: Mapped[datetime] status_id: Mapped[str] = mapped_column(ForeignKey("task_statuses.name")) - status: Mapped["TaskStatusOption"] = relationship(back_populates="tasks", lazy="selectin") + status: Mapped["TaskStatusOption"] = relationship( + back_populates="tasks", lazy="selectin" + ) requirements: Mapped[list["TaskRequirementOption"]] = relationship( secondary=task_requirements, lazy="selectin" ) @@ -61,7 +66,9 @@ class TaskRequirementCategory(Base, OptionMixin): back_populates="parent_category", lazy="selectin" ) parent_category: Mapped["TaskRequirementCategory"] = relationship( - back_populates="sub_categories", remote_side="TaskRequirementCategory.name", lazy="selectin" + back_populates="sub_categories", + remote_side="TaskRequirementCategory.name", + lazy="selectin", ) task_requirement_options: Mapped[list["TaskRequirementOption"]] = relationship( back_populates="category", lazy="selectin" diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index 1871983..4ed3565 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -1,11 +1,13 @@ from fastapi import status, HTTPException from fastapi.routing import APIRouter from sqlalchemy import select, update + from app.dependencies import SessionDep from app.models import Task from app.schemas import TaskModel, TaskUpdate +from app.utils.fsm import TaskStatus -router = APIRouter(prefix="/tasks", tags=["tasks"]) +router = APIRouter(prefix="/tournaments/{tournament_id}/tasks", tags=["tasks"]) async def get_task(task_id: int, session: SessionDep) -> Task: @@ -17,10 +19,10 @@ async def get_task(task_id: int, session: SessionDep) -> Task: @router.get("/", response_model=list[TaskModel], status_code=status.HTTP_200_OK) -async def tasks(session: SessionDep): - statement = select(Task) - users = await session.execute(statement) - return users.scalars().all() +async def tasks(tournament_id: int, session: SessionDep): + statement = select(Task).where(Task.tournament_id == tournament_id) + result = await session.execute(statement) + return result.scalars().all() @router.get("/{task_id}/", response_model=TaskModel, status_code=status.HTTP_200_OK) @@ -29,7 +31,7 @@ async def task(task_id: int, session: SessionDep): @router.post("/", response_model=TaskModel, status_code=status.HTTP_201_CREATED) -async def create_task(task_data: TaskModel, session: SessionDep): +async def create_task(tournament_id: int, task_data: TaskModel, session: SessionDep): new_task = Task(**task_data.model_dump()) session.add(new_task) diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index c7400bb..9b3146b 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -3,7 +3,7 @@ from sqlalchemy import select, update from app.schemas import ( - TournamentRead, + TournamentPublic, TournamentCreate, TournamentUpdate, ) @@ -13,7 +13,7 @@ validate_dates_on_create, validate_dates_on_update, ) -from app.utils.routes import auto_update_tournament_status, get_status_by_name +from app.utils.fsm import auto_update_tournament_status, get_status_by_name router = APIRouter(prefix="/tournaments", tags=["tournaments"]) @@ -34,7 +34,7 @@ async def get_tournament(tournament_id: int, session: SessionDep) -> Tournament: return tournament -@router.get("/", response_model=list[TournamentRead], status_code=status.HTTP_200_OK) +@router.get("/", response_model=list[TournamentPublic], status_code=status.HTTP_200_OK) async def tournaments(session: SessionDep): statement = select(Tournament).options(selectinload(Tournament.status)) tournaments = await session.execute(statement) @@ -42,13 +42,13 @@ async def tournaments(session: SessionDep): @router.get( - "/{tournament_id}/", response_model=TournamentRead, status_code=status.HTTP_200_OK + "/{tournament_id}/", response_model=TournamentPublic, status_code=status.HTTP_200_OK ) async def tournament(tournament_id: int, session: SessionDep): return await get_tournament(tournament_id, session) -@router.post("/", response_model=TournamentRead, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=TournamentPublic, status_code=status.HTTP_201_CREATED) async def create_tournament( tournament: TournamentCreate, session: SessionDep, @@ -74,7 +74,7 @@ async def create_tournament( @router.patch( - "/{tournament_id}/", response_model=TournamentRead, status_code=status.HTTP_200_OK + "/{tournament_id}/", response_model=TournamentPublic, status_code=status.HTTP_200_OK ) async def update_tournament( tournament_id: int, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e958fe6..97fecd8 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -6,7 +6,7 @@ TournamentBase, TournamentUpdate, TournamentCreate, - TournamentRead, + TournamentPublic, TournamentStatusOptionModel, ) from .user import UserModel, UserPublic, UserUpdate diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 8f8b200..4b470f9 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from typing_extensions import Self -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator class TaskBase(BaseModel): @@ -8,6 +8,9 @@ class TaskBase(BaseModel): description: str | None = Field( None, description="A detailed description of what needs to be done" ) + start_time: datetime + end_time: datetime + requirements: list[str] = Field(...) @field_validator("title") @classmethod @@ -16,22 +19,6 @@ def check_title(cls, value: str): raise ValueError("Title cannot be empty") return value.strip() - -class TaskUpdate(TaskBase): - title: str | None = Field(None, min_length=3) - description: str | None = None - start_time: datetime | None = None - end_time: datetime | None = None - tournament_id: int | None = Field(None, gt=0) - status_id: int | None = Field(None, gt=0) - - -class TaskModel(TaskBase): - start_time: datetime - end_time: datetime - tournament_id: int = Field(..., gt=0) - status_id: int = Field(..., gt=0) - @field_validator("start_time") @classmethod def start_not_past(cls, value: datetime): @@ -54,3 +41,23 @@ def check_time_logic(self) -> Self: if self.end_time <= self.start_time: raise ValueError("end_time must be later than start_time") return self + + +class TaskCreate(TaskBase): + tournament_id: int + + +class TaskUpdate(BaseModel): + title: str | None = Field(None, min_length=3) + description: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + status_id: str | None = None + + +class TaskPublic(TaskBase): + model_config = ConfigDict(from_attributes=True) + + id: int + tournament_id: int = Field(..., gt=0) + status_id: str = Field(...) diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 8605692..94233e0 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -35,7 +35,7 @@ class TournamentStatusOptionModel(BaseModel): name: StrippedStr = Field(..., min_length=3) -class TournamentRead(TournamentBase): +class TournamentPublic(TournamentBase): model_config = ConfigDict(from_attributes=True) id: int diff --git a/backend/app/utils/fsm/__init__.py b/backend/app/utils/fsm/__init__.py new file mode 100644 index 0000000..4347647 --- /dev/null +++ b/backend/app/utils/fsm/__init__.py @@ -0,0 +1,2 @@ +from .task_status import TaskStatus +from .tournament_status import auto_update_tournament_status, get_status_by_name diff --git a/backend/app/utils/fsm/task_status.py b/backend/app/utils/fsm/task_status.py new file mode 100644 index 0000000..9f5779f --- /dev/null +++ b/backend/app/utils/fsm/task_status.py @@ -0,0 +1,55 @@ +from datetime import datetime, timezone +from sqlalchemy import select +from statemachine import StateMachine, State + +from app.models import Task +from app.dependencies import SessionDep + + +class TaskStatus(StateMachine): + draft = State("Draft", value="draft", initial=True) + active = State("Active", value="active") + submission_closed = State("SubmissionClosed", value="submissionclosed") + evaluated = State("Evaluated", value="evaluated", final=True) + + start = draft.to(active) + close = active.to(submission_closed) + evaluate = submission_closed.to(evaluated) + + def __init__(self, task: Task): + self.task = task + super().__init__(model=self.task, state_field="status_id") + + def update_by_time(self): + + now = datetime.now(timezone.utc) + + start = self.task.start_time.replace(tzinfo=timezone.utc) + end = self.task.end_time.replace(tzinfo=timezone.utc) + + changed = False + + if self.current_state == self.draft and now >= start: + self.start() + changed = True + + if self.current_state == self.active and now > end: + self.close() + changed = True + + return changed + + +async def update_tasks_status(session: SessionDep): + result = await session.execute(select(Task)) + tasks = result.scalars().all() + + changed = False + + for task in tasks: + fsm = TaskStatus(task) + if fsm.update_by_time(): + changed = True + + if changed: + await session.commit() diff --git a/backend/app/utils/routes/tournament_status.py b/backend/app/utils/fsm/tournament_status.py similarity index 100% rename from backend/app/utils/routes/tournament_status.py rename to backend/app/utils/fsm/tournament_status.py diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py index 51b0741..5bc1889 100644 --- a/backend/app/utils/routes/__init__.py +++ b/backend/app/utils/routes/__init__.py @@ -6,4 +6,3 @@ validate_team_registration, create_team, ) -from .tournament_status import auto_update_tournament_status, get_status_by_name From 916143a2b405fccc8afc80d52f72dfdc76993159 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 10 Apr 2026 22:17:16 +0100 Subject: [PATCH 142/369] feat: add seed for categories --- backend/app/core/seeds/__init__.py | 2 + backend/app/core/seeds/categories.py | 61 ++++++++++++++++++++++++++++ backend/app/main.py | 4 +- backend/app/routes/tasks.py | 23 ++++++----- backend/app/schemas/__init__.py | 2 +- 5 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 backend/app/core/seeds/categories.py diff --git a/backend/app/core/seeds/__init__.py b/backend/app/core/seeds/__init__.py index e69de29..5962e65 100644 --- a/backend/app/core/seeds/__init__.py +++ b/backend/app/core/seeds/__init__.py @@ -0,0 +1,2 @@ +from .categories import init_categories +from .status import init_task_statuses, init_tournament_statuses \ No newline at end of file diff --git a/backend/app/core/seeds/categories.py b/backend/app/core/seeds/categories.py new file mode 100644 index 0000000..157b325 --- /dev/null +++ b/backend/app/core/seeds/categories.py @@ -0,0 +1,61 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import TaskRequirementCategory + +CATEGORIES = [ + {"name": "Languages", "main_id": None}, + {"name": "Backend", "main_id": None}, + {"name": "Frontend", "main_id": None}, + {"name": "Databases", "main_id": None}, + {"name": "Infrastructure", "main_id": None}, + {"name": "Mobile", "main_id": None}, + {"name": "Design & UI/UX", "main_id": None}, + {"name": "SQL", "main_id": "Databases"}, + {"name": "NoSQL", "main_id": "Databases"}, + {"name": "Vector DB", "main_id": "Databases"}, + {"name": "Frameworks", "main_id": "Backend"}, + {"name": "JS Frameworks", "main_id": "Frontend"}, + {"name": "State Management", "main_id": "Frontend"}, + {"name": "DevOps", "main_id": "Infrastructure"}, + {"name": "Cloud", "main_id": "Infrastructure"}, + {"name": "Monitoring", "main_id": "Infrastructure"}, +] + + +async def init_categories(session: AsyncSession): + parents = [p for p in CATEGORIES if p["main_id"] is None] + children = [c for c in CATEGORIES if c["main_id"] is not None] + + for item in parents: + stmt = select(TaskRequirementCategory).where( + TaskRequirementCategory.name == item["name"] + ) + result = await session.execute(stmt) + if not result.scalar_one_or_none(): + session.add( + TaskRequirementCategory( + name=item["name"], display_name=item["name"], main_id=None + ) + ) + + await session.commit() + + for item in children: + stmt = select(TaskRequirementCategory).where( + TaskRequirementCategory.name == item["name"] + ) + if not (await session.execute(stmt)).scalar_one_or_none(): + p_stmt = select(TaskRequirementCategory).where( + TaskRequirementCategory.name == item["main_id"] + ) + if (await session.execute(p_stmt)).scalar_one_or_none(): + session.add( + TaskRequirementCategory( + name=item["name"], + display_name=item["name"], + main_id=item["main_id"], + ) + ) + + await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 8e790bc..6ed74b5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ import app.routes.teams as teams import app.routes.team_members as team_members -from app.core.seeds.status import init_tournament_statuses, init_task_statuses +from app.core.seeds import init_tournament_statuses, init_task_statuses, init_categories from app.db import AsyncSessionLocal @@ -18,7 +18,7 @@ async def lifespan(app: FastAPI): async with AsyncSessionLocal() as session: await init_tournament_statuses(session) await init_task_statuses(session) - + await init_categories(session) yield diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index 4ed3565..a0a016c 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -4,7 +4,7 @@ from app.dependencies import SessionDep from app.models import Task -from app.schemas import TaskModel, TaskUpdate +from app.schemas import TaskCreate, TaskUpdate, TaskPublic from app.utils.fsm import TaskStatus router = APIRouter(prefix="/tournaments/{tournament_id}/tasks", tags=["tasks"]) @@ -18,29 +18,30 @@ async def get_task(task_id: int, session: SessionDep) -> Task: return task -@router.get("/", response_model=list[TaskModel], status_code=status.HTTP_200_OK) +@router.get("/", response_model=list[TaskPublic], status_code=status.HTTP_200_OK) async def tasks(tournament_id: int, session: SessionDep): statement = select(Task).where(Task.tournament_id == tournament_id) result = await session.execute(statement) return result.scalars().all() -@router.get("/{task_id}/", response_model=TaskModel, status_code=status.HTTP_200_OK) +@router.get("/{task_id}/", response_model=TaskPublic, status_code=status.HTTP_200_OK) async def task(task_id: int, session: SessionDep): return await get_task(task_id, session) -@router.post("/", response_model=TaskModel, status_code=status.HTTP_201_CREATED) -async def create_task(tournament_id: int, task_data: TaskModel, session: SessionDep): - new_task = Task(**task_data.model_dump()) +# @router.post("/", response_model=TaskPublic, status_code=status.HTTP_201_CREATED) +# async def create_task(tournament_id: int, task_data: TaskCreate, session: SessionDep): +# task_dict = task_data.model_dump(exclude={"requirements"}) +# new_task = Task(**task_dict) - session.add(new_task) - await session.commit() - await session.refresh(new_task) - return new_task +# session.add(new_task) +# await session.commit() +# await session.refresh(new_task) +# return new_task -@router.patch("/{task_id}/", response_model=TaskModel, status_code=status.HTTP_200_OK) +@router.patch("/{task_id}/", response_model=TaskPublic, status_code=status.HTTP_200_OK) async def update_task(task_id: int, task_data: TaskUpdate, session: SessionDep): update_data = task_data.model_dump(exclude_unset=True) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 97fecd8..1b5d6a4 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,6 +1,6 @@ from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionModel -from .task import TaskBase, TaskUpdate, TaskModel +from .task import TaskBase, TaskCreate,TaskUpdate, TaskPublic from .team import TeamModel, TeamUpdate, TeamMemberModel, TeamMemberUpdate from .tournament import ( TournamentBase, From 0927a86fdaf491c60a141f99d9e501136e409b0a Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Fri, 10 Apr 2026 22:48:53 +0100 Subject: [PATCH 143/369] feat: add new endpoints for task options --- backend/app/main.py | 2 ++ backend/app/routes/task_options.py | 56 +++++++++++++++++++++++++++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/task_options.py | 13 +++++++ 4 files changed, 72 insertions(+) create mode 100644 backend/app/routes/task_options.py create mode 100644 backend/app/schemas/task_options.py diff --git a/backend/app/main.py b/backend/app/main.py index 6ed74b5..ab11ec5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ import app.routes.submissions as submissions import app.routes.teams as teams import app.routes.team_members as team_members +import app.routes.task_options as task_options from app.core.seeds import init_tournament_statuses, init_task_statuses, init_categories from app.db import AsyncSessionLocal @@ -31,3 +32,4 @@ async def lifespan(app: FastAPI): app.include_router(submissions.router) app.include_router(teams.router) app.include_router(team_members.router) +app.include_router(task_options.router) diff --git a/backend/app/routes/task_options.py b/backend/app/routes/task_options.py new file mode 100644 index 0000000..4a1e4d8 --- /dev/null +++ b/backend/app/routes/task_options.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select +from app.models import TaskRequirementOption, TaskRequirementCategory +from app.schemas import TaskRequirementOptionCreate, TaskRequirementOptionPublic +from app.dependencies import SessionDep + +router = APIRouter(prefix="/task-options", tags=["task options"]) + + +@router.get( + "/", + response_model=list[TaskRequirementOptionPublic], + status_code=status.HTTP_200_OK, +) +async def get_all_options(session: SessionDep): + result = await session.execute(select(TaskRequirementOption)) + return result.scalars().all() + + +@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_option(name: str, session: SessionDep): + option = await session.get(TaskRequirementOption, name) + if not option: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Option not found" + ) + + await session.delete(option) + await session.commit() + + +@router.post( + "/", response_model=TaskRequirementOptionPublic, status_code=status.HTTP_201_CREATED +) +async def create_requirement_option( + data: TaskRequirementOptionCreate, session: SessionDep +): + category = await session.get(TaskRequirementCategory, data.category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Category '{data.category_id}' not found", + ) + + existing = await session.get(TaskRequirementOption, data.name) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Option with this name already exists", + ) + + new_option = TaskRequirementOption(**data.model_dump()) + session.add(new_option) + await session.commit() + await session.refresh(new_option) + return new_option diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 1b5d6a4..fd2a4f4 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,5 +1,6 @@ from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionModel +from .task_options import TaskRequirementOptionCreate, TaskRequirementOptionPublic from .task import TaskBase, TaskCreate,TaskUpdate, TaskPublic from .team import TeamModel, TeamUpdate, TeamMemberModel, TeamMemberUpdate from .tournament import ( diff --git a/backend/app/schemas/task_options.py b/backend/app/schemas/task_options.py new file mode 100644 index 0000000..976d39a --- /dev/null +++ b/backend/app/schemas/task_options.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class TaskRequirementOptionCreate(BaseModel): + name: str = Field(..., min_length=1, description="Technical ID (e.g. “fastapi”)") + display_name: str = Field( + ..., min_length=1, description="A human-readable name (e.g., “FastAPI”)" + ) + category_id: str = Field(..., description="ID of an existing category") + + +class TaskRequirementOptionPublic(TaskRequirementOptionCreate): + pass From 57afa0806f93f3857462a6f38eb91cb05d4a0853 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 11 Apr 2026 19:49:03 +0300 Subject: [PATCH 144/369] fix: display name is null when user is created using email and password --- backend/app/dependencies/current_user.py | 17 ++++--- frontend/src/api/requests/getProfile.ts | 1 - frontend/src/firebase.ts | 64 +++++++++++++++++------- frontend/src/pages/Auth/AuthPage.tsx | 10 ++-- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py index 1d2135f..b165849 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -20,14 +20,17 @@ async def get_current_user( ) -> User: try: token = auth.verify_id_token(token.credentials, firebase) - u = auth.get_user_by_email(token['email']) + u: auth.UserRecord = auth.get_user_by_email(token['email']) try: user = await get_user(u.uid, session) - except HTTPException: - user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) - session.add(user) - await session.commit() - await session.refresh(user) + except HTTPException as e: + if e.status_code == status.HTTP_404_NOT_FOUND: + if not u.display_name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Full name is null!') + user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) + session.add(user) + await session.commit() + await session.refresh(user) except ( ValueError, InvalidIdTokenError, @@ -41,4 +44,4 @@ async def get_current_user( return user -CurrentUserDep = Annotated[User, Depends(get_current_user)] \ No newline at end of file +CurrentUserDep = Annotated[User, Depends(get_current_user)] diff --git a/frontend/src/api/requests/getProfile.ts b/frontend/src/api/requests/getProfile.ts index a5864d4..75ad646 100644 --- a/frontend/src/api/requests/getProfile.ts +++ b/frontend/src/api/requests/getProfile.ts @@ -4,7 +4,6 @@ import apiClient from "../client"; export const getProfile = async (user: User) => { try { const token = await user.getIdToken(); - console.log(token) const resp = await apiClient.get('/profile/', { headers: { Authorization: `Bearer ${token}`, diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index af8389f..0f9ecf9 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -1,6 +1,6 @@ import { initializeApp, type FirebaseOptions } from "firebase/app"; import { getAnalytics } from "firebase/analytics"; -import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence } from "firebase/auth"; +import { browserLocalPersistence, getAuth, GoogleAuthProvider, onAuthStateChanged, setPersistence, type User } from "firebase/auth"; import { initializeUI, providerPopupStrategy, requireDisplayName } from '@firebase-oss/ui-core'; import { setUser, type ApiUserData, type FirebaseUserData, type UserData } from "./slices/user"; import { store } from "./store"; @@ -33,32 +33,62 @@ const google = new GoogleAuthProvider(); google.addScope('profile'); google.addScope('email'); -onAuthStateChanged(auth, async (user) => { - if (user) { - const firebaseUserData: FirebaseUserData = { - uid: user.uid, - email: user.email, - displayName: user.displayName, - photoURL: user.photoURL, - emailVerified: user.emailVerified, - isAnonymous: user.isAnonymous, - }; +const baseApiUserData: ApiUserData = { + roles: [], + notifications: [], + role_requests: [], + created_tournaments: [], + telegram: undefined, + github: undefined, + discord: undefined, +}; + +export const syncUser = async (user: User | null) => { + if (!user) { + store.dispatch(setUser(null)); + return; + } + + const firebaseUserData: FirebaseUserData = { + uid: user.uid, + email: user.email, + displayName: user.displayName ?? user.email ?? user.uid, + photoURL: user.photoURL, + emailVerified: user.emailVerified, + isAnonymous: user.isAnonymous, + }; + + try { const apiUserData = await queryClient.fetchQuery({ queryKey: ['user', user.uid], - queryFn: async () => await getProfile(user), + queryFn: async () => { + const data = await getProfile(user); + if (!data) return null; + return data; + }, }); + const userData: UserData = { + ...baseApiUserData, ...firebaseUserData, ...apiUserData, }; + store.dispatch(setUser(userData)); - console.log(userData); console.log('User authenticated!'); - } else { - store.dispatch(setUser(null)); - console.log('Sign out!') + } catch (error) { + console.error('Failed to sync user with API, falling back to Firebase data', error); + const userData: UserData = { + ...baseApiUserData, + ...firebaseUserData, + }; + store.dispatch(setUser(userData)); } +}; + +onAuthStateChanged(auth, async (user) => { + await syncUser(user); }); export default app; -export { analytics, auth, ui, google }; \ No newline at end of file +export { analytics, auth, ui, google }; diff --git a/frontend/src/pages/Auth/AuthPage.tsx b/frontend/src/pages/Auth/AuthPage.tsx index d5f1474..52e43bd 100644 --- a/frontend/src/pages/Auth/AuthPage.tsx +++ b/frontend/src/pages/Auth/AuthPage.tsx @@ -15,9 +15,7 @@ import { signInWithPopup, } from "firebase/auth"; import type { FirebaseError } from "firebase/app"; -import { auth, google } from "../../firebase"; -import { store } from "../../store"; -import { setDisplayName } from "../../slices/user"; +import { auth, google, syncUser } from "../../firebase"; import starAnimation from "../../../public/star.json"; @@ -111,10 +109,8 @@ export const AuthPage = () => { data.email, data.password, ); - if (data.displayName) { - await updateProfile(cred.user, { displayName: data.displayName }); - store.dispatch(setDisplayName(data.displayName)); - } + await updateProfile(cred.user, { displayName: data.displayName }); + await syncUser(cred.user); } navigate("/"); } catch (e) { From 15dce582f97521bf358fd0bc24b52ebc806d128a Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 11 Apr 2026 19:51:23 +0300 Subject: [PATCH 145/369] chore: delete SignUp and SignIn pages --- frontend/src/pages/Auth/SignIn.tsx | 18 ------ frontend/src/pages/Auth/SignUp.tsx | 100 ----------------------------- 2 files changed, 118 deletions(-) delete mode 100644 frontend/src/pages/Auth/SignIn.tsx delete mode 100644 frontend/src/pages/Auth/SignUp.tsx diff --git a/frontend/src/pages/Auth/SignIn.tsx b/frontend/src/pages/Auth/SignIn.tsx deleted file mode 100644 index 3dc2f16..0000000 --- a/frontend/src/pages/Auth/SignIn.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { GoogleSignInButton, SignInAuthScreen } from "@firebase-oss/ui-react"; -import { useNavigate } from "react-router-dom"; -import { google } from "../../firebase"; - -const SignIn = () => { - const navigate = useNavigate(); - const handleSignIn = () => { - navigate('/'); - }; - return navigate('/auth/forgot-password/')} - onSignUpClick={() => navigate('/auth/sign-up')}> - - -} - -export default SignIn; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignUp.tsx b/frontend/src/pages/Auth/SignUp.tsx deleted file mode 100644 index d728c70..0000000 --- a/frontend/src/pages/Auth/SignUp.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { GoogleSignInButton, useUI } from "@firebase-oss/ui-react"; -import { Link, useNavigate } from "react-router-dom"; -import { auth, google } from "../../firebase"; -import { useState, type ChangeEvent, type SubmitEvent } from "react"; -import { createUserWithEmailAndPassword, updateProfile } from "firebase/auth"; -import { setDisplayName } from "../../slices/user"; -import { store } from "../../store"; -import type { FirebaseError } from "firebase/app"; -import { getTranslation } from "@firebase-oss/ui-core"; - -const SignUp = () => { - const navigate = useNavigate(); - // The reason we use our own form and not SignUpAuthScreen is - // the display name is set after the user creation using updateProfile - // Because of it the displayName is empty in redux when you register an account - // There is no way to track this event somehow, so we have to update the profile ourselves - const ui = useUI(); - const [formData, setFormData] = useState({ - displayName: "", - email: "", - password: "", - }); - const [formErrors, setFormErrors] = useState>({ - email: null, - password: null, - }); - const handleUpdate = (e: ChangeEvent) => { - const target = e.target; - setFormData({ - ...formData, - [target.name]: target.value, - }); - }; - const handleSignUp = async (e: SubmitEvent) => { - e.preventDefault(); - if (!formData.displayName || !formData.email || !formData.password) return; - try { - const cred = await createUserWithEmailAndPassword( - auth, - formData.email, - formData.password, - ); - await updateProfile(cred.user, { - displayName: formData.displayName, - }); - store.dispatch(setDisplayName(formData.displayName)); - navigate("/"); - } catch (e) { - const err = e as FirebaseError; - console.log(err.code); - // TODO?: Maybe we can find a way to handle the errors properly - if (err.code == "auth/email-already-in-use") { - setFormErrors({ - ...formErrors, - email: getTranslation(ui, "errors", "emailAlreadyInUse"), - }); - } else if (err.code == "auth/password-does-not-meet-requirements") { - setFormErrors({ - ...formErrors, - password: getTranslation(ui, "errors", "weakPassword"), - }); - } - } - }; - return ( -
    - - - - {formErrors.email &&

    {formErrors.email}

    } - - {formErrors.password &&

    {formErrors.password}

    } - - Вже маєте аккаунт? Увійдіть у нього! - - ); -}; - -export default SignUp; From fe599002de3cd6f7c5e476684229afb8d7fd25c0 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 11 Apr 2026 19:55:22 +0300 Subject: [PATCH 146/369] refactor: unify auth routes under one root Route component --- frontend/src/App.tsx | 11 ++++++----- frontend/src/pages/Auth/AuthPage.tsx | 2 +- frontend/src/pages/Auth/ForgotPassword.tsx | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6cb2c8..a5f8ee5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,18 +36,19 @@ export const App = () => { /> } /> } /> - } /> + } /> } /> } /> } /> } /> } /> - } /> } /> - - } /> - } /> + + } /> + } /> + } /> +
    diff --git a/frontend/src/pages/Auth/AuthPage.tsx b/frontend/src/pages/Auth/AuthPage.tsx index 52e43bd..869cb1f 100644 --- a/frontend/src/pages/Auth/AuthPage.tsx +++ b/frontend/src/pages/Auth/AuthPage.tsx @@ -299,7 +299,7 @@ export const AuthPage = () => { Пароль Забули пароль? diff --git a/frontend/src/pages/Auth/ForgotPassword.tsx b/frontend/src/pages/Auth/ForgotPassword.tsx index efc4986..fe5ae6b 100644 --- a/frontend/src/pages/Auth/ForgotPassword.tsx +++ b/frontend/src/pages/Auth/ForgotPassword.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; const ForgotPassword = () => { const navigate = useNavigate(); - return navigate('/auth/sign-in/')}> + return navigate('/auth/')}> ; } From 7bc6356e998c701c6224620f5dcc4f366d50b177 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 11 Apr 2026 20:16:17 +0300 Subject: [PATCH 147/369] feat(profile): update user profile on backend and rewrite the edit profile modal --- backend/app/routes/profile.py | 3 + frontend/src/api/requests/updateProfile.ts | 25 ++++++ frontend/src/api/requests/updateUser.ts | 24 ------ frontend/src/firebase.ts | 1 + .../src/pages/Profile/EditProfileModal.tsx | 86 ++++++++----------- frontend/src/slices/user.ts | 1 + 6 files changed, 67 insertions(+), 73 deletions(-) create mode 100644 frontend/src/api/requests/updateProfile.ts delete mode 100644 frontend/src/api/requests/updateUser.ts diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py index ec7f7d8..6947bb5 100644 --- a/backend/app/routes/profile.py +++ b/backend/app/routes/profile.py @@ -15,6 +15,9 @@ async def edit_profile(session: SessionDep, current_user: CurrentUserDep, user: UserUpdate): user_data = user.model_dump(exclude_unset=True) current_user.full_name = user_data['full_name'] + current_user.telegram = user_data['telegram'] + current_user.github = user_data['github'] + current_user.discord = user_data['discord'] await session.commit() return current_user diff --git a/frontend/src/api/requests/updateProfile.ts b/frontend/src/api/requests/updateProfile.ts new file mode 100644 index 0000000..d408235 --- /dev/null +++ b/frontend/src/api/requests/updateProfile.ts @@ -0,0 +1,25 @@ +import type { User } from "firebase/auth"; +import apiClient from "../client"; + +interface UpdateUserData { + full_name?: string; + telegram?: string; + github?: string; + discord?: string; +} + +export const updateProfile = async (user: User, data: UpdateUserData) => { + try { + const token = await user.getIdToken(); + const response = await apiClient.patch(`/profile/`, data, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data; + } catch (e) { + console.error(`Error occurred: ${e}`); + throw e + } +} \ No newline at end of file diff --git a/frontend/src/api/requests/updateUser.ts b/frontend/src/api/requests/updateUser.ts deleted file mode 100644 index df7b79e..0000000 --- a/frontend/src/api/requests/updateUser.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { auth } from "../../firebase"; -import apiClient from "../client"; - -export const updateUser = async (userId: string, data: any) => { - try { - const currentUser = auth.currentUser; - - if (!currentUser) { - throw new Error("Користувач не авторизований у Firebase"); - } - - const token = await currentUser.getIdToken(); - const response = await apiClient.patch(`/users/${userId}/`, data, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - return response.data; - } catch (e) { - console.error(`Error occurred: ${e}`); - throw e - } -} \ No newline at end of file diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 0f9ecf9..5dfa20c 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -34,6 +34,7 @@ google.addScope('profile'); google.addScope('email'); const baseApiUserData: ApiUserData = { + id: -1, roles: [], notifications: [], role_requests: [], diff --git a/frontend/src/pages/Profile/EditProfileModal.tsx b/frontend/src/pages/Profile/EditProfileModal.tsx index f5e406b..53de13e 100644 --- a/frontend/src/pages/Profile/EditProfileModal.tsx +++ b/frontend/src/pages/Profile/EditProfileModal.tsx @@ -1,54 +1,54 @@ -import React, { useState } from "react"; +import React, { useState, type SubmitEvent } from "react"; import { useMutation } from "@tanstack/react-query"; -import { store } from "../../store"; +import { store, type RootState } from "../../store"; import { setUser } from "@/slices/user"; -import { updateUser } from "@/api/requests/updateUser"; +import { updateProfile } from "@/api/requests/updateProfile"; +import { useSelector } from "react-redux"; +import { auth } from "@/firebase"; interface EditProfileModalProps { isOpen: boolean; onClose: () => void; - currentUser: any; } -export const EditProfileModal: React.FC = ({ isOpen, onClose, currentUser }) => { +export const EditProfileModal: React.FC = ({ isOpen, onClose }) => { + const user = useSelector((s: RootState) => s.user.user); + // Email should be updated elsewhere, because this process requires confirmation that the new email belongs to user const [formData, setFormData] = useState({ - full_name: currentUser?.displayName || "", - email: currentUser?.email || "hacker777@example.com", - telegram: currentUser?.telegram || "", - github: currentUser?.github || "", - discord: currentUser?.discord || "", + full_name: user?.displayName ?? "", + telegram: user?.telegram ?? "", + github: user?.github ?? "", + discord: user?.discord ?? "", }); const updateMutation = useMutation({ // Використовуємо uid, або id як запасний варіант - mutationKey: ["update user", currentUser?.uid || currentUser?.id], + mutationKey: ["update user", user?.uid], mutationFn: async (data: typeof formData) => { - const userId = currentUser.uid || currentUser.id; - if (!userId) throw new Error("ID користувача не знайдено!"); - - return await updateUser(userId, data); + if (!auth.currentUser) return; + return await updateProfile(auth.currentUser, data); }, onSuccess: (variables) => { - store.dispatch( - setUser({ - ...currentUser, - displayName: variables.full_name, - ...variables - }) - ); - onClose(); + store.dispatch( + setUser({ + ...user, + displayName: variables.full_name, + ...variables + }) + ); + onClose(); }, onError: (e: any) => { - console.error("Помилка при оновленні профілю", e.message); + console.error("Помилка при оновленні профілю", e.message); }, - }); + }); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = (e: SubmitEvent) => { e.preventDefault(); updateMutation.mutate(formData); }; @@ -62,29 +62,17 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl

    Редагувати профіль

    - -
    -
    - - -
    +
    - - Повне ім'я +
    @@ -108,9 +96,9 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl
    - @@ -155,7 +156,7 @@ const RoleRequestPage = () => {
    - +
    @@ -171,9 +172,9 @@ const RoleRequestPage = () => {
    - - + ); @@ -235,7 +235,7 @@ export const TournamentPage = () => {
    - +
    @@ -296,11 +296,10 @@ const TournamentMainContent = ({ tournament }: { tournament: TournamentData }) = tabsRef.current[index] = el; }} onClick={() => setActiveTab(tab.id)} - className={`font-quicksand font-bold text-[20px] md:text-[22px] cursor-pointer relative z-10 transition-colors duration-300 px-2 py-1 ${ - activeTab === tab.id + className={`font-quicksand font-bold text-[20px] md:text-[22px] cursor-pointer relative z-10 transition-colors duration-300 px-2 py-1 ${activeTab === tab.id ? "text-primary" : "text-slate-400 hover:text-primary/70" - }`} + }`} > {tab.label} From 5e48d9eaf49dd2dfb723c3387b713a10bd258a57 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 11 Apr 2026 22:53:44 +0300 Subject: [PATCH 152/369] test: update profile test will not pass because telegram keyword was not specified --- backend/app/routes/profile.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py index 6947bb5..81f7650 100644 --- a/backend/app/routes/profile.py +++ b/backend/app/routes/profile.py @@ -14,10 +14,14 @@ async def get_profile(current_user: CurrentUserDep): async def edit_profile(session: SessionDep, current_user: CurrentUserDep, user: UserUpdate): user_data = user.model_dump(exclude_unset=True) - current_user.full_name = user_data['full_name'] - current_user.telegram = user_data['telegram'] - current_user.github = user_data['github'] - current_user.discord = user_data['discord'] + if 'full_name' in user_data: + current_user.full_name = user_data['full_name'] + if 'telegram' in user_data: + current_user.telegram = user_data['telegram'] + if 'github' in user_data: + current_user.github = user_data['github'] + if 'discord' in user_data: + current_user.discord = user_data['discord'] await session.commit() return current_user From 438a567d925787c7c5004280cc062d046d6ba7e9 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 11 Apr 2026 22:56:47 +0300 Subject: [PATCH 153/369] refactor(models): make TeamMember.educational_instituion field optional --- backend/alembic/versions/5b67e3a0a3b6_.py | 40 +++++++++++++++++++++++ backend/app/models/team.py | 4 +-- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/5b67e3a0a3b6_.py diff --git a/backend/alembic/versions/5b67e3a0a3b6_.py b/backend/alembic/versions/5b67e3a0a3b6_.py new file mode 100644 index 0000000..36c7e0c --- /dev/null +++ b/backend/alembic/versions/5b67e3a0a3b6_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 5b67e3a0a3b6 +Revises: 7b2a5bd55133 +Create Date: 2026-04-11 22:56:15.825841 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5b67e3a0a3b6' +down_revision: Union[str, Sequence[str], None] = '7b2a5bd55133' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('team_members', schema=None) as batch_op: + batch_op.alter_column('educational_institution', + existing_type=sa.VARCHAR(), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('team_members', schema=None) as batch_op: + batch_op.alter_column('educational_institution', + existing_type=sa.VARCHAR(), + nullable=False) + + # ### end Alembic commands ### diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 03ca816..c7d1729 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,6 +1,6 @@ from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship - +from typing import Optional from .base import Base from .mixin import PKMixin @@ -40,7 +40,7 @@ class TeamMember(Base, PKMixin): full_name: Mapped[str] = mapped_column(nullable=False) email: Mapped[str] = mapped_column(nullable=False) telegram: Mapped[str] = mapped_column(nullable=False) - educational_institution: Mapped[str] = mapped_column(nullable=False) + educational_institution: Mapped[Optional[str]] = mapped_column(nullable=True) team_id: Mapped[int] = mapped_column( ForeignKey("teams.id", use_alter=True, name="fk_teammember_team") ) From c488514d1785ad10a4bbc8241791d834dde98fe3 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Sun, 12 Apr 2026 21:59:31 +0300 Subject: [PATCH 154/369] test: rewrite test for tournamentPage, profile and getRole page --- .../src/pages/GetRole/RoleRequst.test.tsx | 17 +- frontend/src/pages/Profile/Profile.test.tsx | 71 +++++-- .../__snapshots__/Profile.test.tsx.snap | 28 +-- .../TournamentPage/TournamentPage.test.tsx | 62 +++++- .../TournamentPage.test.tsx.snap | 197 +++++------------- 5 files changed, 181 insertions(+), 194 deletions(-) diff --git a/frontend/src/pages/GetRole/RoleRequst.test.tsx b/frontend/src/pages/GetRole/RoleRequst.test.tsx index 4baedc1..14764ea 100644 --- a/frontend/src/pages/GetRole/RoleRequst.test.tsx +++ b/frontend/src/pages/GetRole/RoleRequst.test.tsx @@ -15,7 +15,6 @@ vi.mock('react-router-dom', () => ({ useNavigate: vi.fn(), })); - vi.mock('react-hot-toast', () => ({ default: { success: vi.fn(), @@ -23,7 +22,6 @@ vi.mock('react-hot-toast', () => ({ }, })); - vi.mock('@/api/requests/requestRole', () => ({ requestRole: vi.fn(), })); @@ -34,6 +32,12 @@ vi.mock('@/api/client', () => ({ }, })); +vi.mock('@/firebase', () => ({ + auth: { + currentUser: { uid: 'mock-user-123' }, + }, +})); + vi.mock('lottie-react', () => ({ default: () =>
    , })); @@ -63,12 +67,13 @@ describe('RoleRequestPage Component', () => { it('fetches and displays roles correctly, filtering out "user"', async () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); render(); + await waitFor(() => { expect(screen.getByText('Administrator')).toBeInTheDocument(); expect(screen.getByText('Moderator')).toBeInTheDocument(); }); + expect(screen.queryByText('User')).not.toBeInTheDocument(); - expect(screen.getByText('Заявка на роль administrator')).toBeInTheDocument(); }); @@ -94,9 +99,9 @@ describe('RoleRequestPage Component', () => { expect(toast.error).toHaveBeenCalledWith('Користувач не знайдений або не авторизований!'); }); - it('submits form as Tolka and navigates on success', async () => { + it('submits form successfully and navigates', async () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(requestRole).mockResolvedValue(200); + vi.mocked(requestRole).mockResolvedValue(200 as any); const { container } = render(); @@ -114,7 +119,7 @@ describe('RoleRequestPage Component', () => { fireEvent.submit(container.querySelector('form')!); await waitFor(() => { - expect(requestRole).toHaveBeenCalledWith('moderator', '123', [ + expect(requestRole).toHaveBeenCalledWith('moderator', { uid: 'mock-user-123' }, '123', [ { option_name: 'ПІБ', value: 'Толька' }, { option_name: 'Контакти', value: '@tolka_boss' }, { option_name: 'Вік', value: '22' }, diff --git a/frontend/src/pages/Profile/Profile.test.tsx b/frontend/src/pages/Profile/Profile.test.tsx index 636821a..7672999 100644 --- a/frontend/src/pages/Profile/Profile.test.tsx +++ b/frontend/src/pages/Profile/Profile.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { useSelector } from 'react-redux'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Profile } from './Profile'; import { useMutation } from '@tanstack/react-query'; @@ -19,10 +19,32 @@ vi.mock('../../firebase', () => ({ }, })); +vi.mock('./EditProfileModal', () => ({ + EditProfileModal: ({ isOpen }: { isOpen: boolean }) => ( + isOpen ?
    Модалка відкрита
    : null + ), +})); + +const mockUserFull = { + displayName: 'Супер Хакер', + email: 'hacker777@example.com', + telegram: '@hacker777', + github: 'hacker777', + discord: 'hacker#7777', +}; + +const mockUserPartial = { + displayName: 'Тестер', +}; + describe('Profile Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useMutation).mockReturnValue({ mutate: vi.fn() } as any); + }); it('renders correctly and matches snapshot', () => { - vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + vi.mocked(useSelector).mockReturnValue(mockUserFull); const { container } = render(); expect(container).toMatchSnapshot(); }); @@ -30,41 +52,58 @@ describe('Profile Component', () => { it('shows "Loading..." when user is null', () => { vi.mocked(useSelector).mockReturnValue(null); render(); - const loadingText = screen.getByText(/loading/i); - expect(loadingText).toBeInTheDocument(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); it('calls delete mutation on delete button click', () => { const mockMutate = vi.fn(); vi.mocked(useMutation).mockReturnValue({ mutate: mockMutate } as any); - vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + vi.mocked(useSelector).mockReturnValue(mockUserFull); render(); - const deleteButton = screen.getByText("Видалити профіль"); - fireEvent.click(deleteButton); + + fireEvent.click(screen.getByText("Видалити профіль")); expect(mockMutate).toHaveBeenCalled(); }); + it('opens edit modal on edit button click', () => { + vi.mocked(useSelector).mockReturnValue(mockUserFull); + render(); + + expect(screen.queryByTestId('edit-profile-modal')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Редагувати профіль")); + expect(screen.getByTestId('edit-profile-modal')).toBeInTheDocument(); + }); + it('displays user name and role', () => { - vi.mocked(useSelector).mockReturnValue({ displayName: 'Супер Хакер' }); + vi.mocked(useSelector).mockReturnValue(mockUserFull); render(); + expect(screen.getByText('Супер Хакер')).toBeInTheDocument(); expect(screen.getByText('Роль: Користувач')).toBeInTheDocument(); }); - it('displays correct contact details', () => { - vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + it('displays correct contact details when available', () => { + vi.mocked(useSelector).mockReturnValue(mockUserFull); render(); - const emailLink = screen.getByRole('link', { name: 'hacker777@example.com' }); - expect(emailLink).toBeInTheDocument(); - expect(emailLink).toHaveAttribute('href', 'mailto:hacker777@example.com'); + + expect(screen.getByText('hacker777@example.com')).toBeInTheDocument(); + expect(screen.getByText('@hacker777')).toBeInTheDocument(); expect(screen.getByText('hacker777')).toBeInTheDocument(); + expect(screen.getByText('hacker#7777')).toBeInTheDocument(); + }); + + it('displays "Відсутній" for missing contact details', () => { + vi.mocked(useSelector).mockReturnValue(mockUserPartial); + render(); + + const missingBadges = screen.getAllByText('Відсутній'); + expect(missingBadges).toHaveLength(4); }); it('displays tournament and team lists', () => { - vi.mocked(useSelector).mockReturnValue({ displayName: 'Тестер' }); + vi.mocked(useSelector).mockReturnValue(mockUserFull); render(); - expect(screen.getByText('Турніри')).toBeInTheDocument(); - expect(screen.getByText('Команди')).toBeInTheDocument(); + expect(screen.getByText('Напишіть Ядро Лінукс')).toBeInTheDocument(); expect(screen.getByText('Шалені програмісти')).toBeInTheDocument(); expect(screen.getByText('Лінус Торвальдс')).toBeInTheDocument(); diff --git a/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap b/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap index 80ea95e..c9c2211 100644 --- a/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap +++ b/frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap @@ -42,7 +42,7 @@ exports[`Profile Component > renders correctly and matches snapshot 1`] = `

    - Тестер + Супер Хакер

    renders correctly and matches snapshot 1`] = ` > Email: - hacker777@example.com - +
    renders correctly and matches snapshot 1`] = ` > Telegram: - @hacker777 - +
    renders correctly and matches snapshot 1`] = ` > GitHub: - hacker777 - +
    renders correctly and matches snapshot 1`] = ` > Discord: - - @hacker777 - + hacker#7777 +
    diff --git a/frontend/src/pages/TournamentPage/TournamentPage.test.tsx b/frontend/src/pages/TournamentPage/TournamentPage.test.tsx index ebfeef5..4adee84 100644 --- a/frontend/src/pages/TournamentPage/TournamentPage.test.tsx +++ b/frontend/src/pages/TournamentPage/TournamentPage.test.tsx @@ -1,36 +1,76 @@ -import { vi, describe, it, expect } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TournamentPage } from './TournamentPage'; +import apiClient from '@/api/client'; -vi.mock('../../components/Header', () => ({ - Header: () =>
    Фейковий Хедер
    +vi.mock('@/api/client', () => ({ + default: { + get: vi.fn(), + }, +})); + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ id: '123' }), })); vi.mock('../../components/Hero', () => ({ - Hero: () =>
    Фейковий Хіро з анімацією
    + Hero: ({ title }: any) =>
    {title}
    })); +const mockTournament = { + title: "SLOVO JAM", + description: "Тестовий опис завдання турніру", + start_date: "2026-05-01T10:00:00Z", + reg_start: "2026-04-01T10:00:00Z", + reg_end: "2026-04-20T10:00:00Z", + max_teams: 10 +}; + const placeholderTabs = ['Шукають команду', 'Команди (3)', 'Результати']; describe('TournamentPage', () => { - it('matches snapshot', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); + beforeEach(() => { + vi.clearAllMocks(); + (apiClient.get as any).mockResolvedValue({ data: mockTournament }); }); - it('shows DescriptionTab by default', () => { + it('renders loading state initially', () => { render(); - expect(screen.getByText('Що потрібно зробити?')).toBeInTheDocument(); + expect(screen.getByText('Завантаження турніру...')).toBeInTheDocument(); + }); + + it('shows error state on API failure', async () => { + (apiClient.get as any).mockRejectedValueOnce({ response: { data: { message: 'Помилка сервера' } } }); + render(); + + await waitFor(() => { + expect(screen.getByText('Ой, халепа!')).toBeInTheDocument(); + expect(screen.getByText('Помилка сервера')).toBeInTheDocument(); + }); + }); + + it('shows DescriptionTab by default after successful data fetch', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Що потрібно зробити?')).toBeInTheDocument(); + }); + + expect(screen.getByText(mockTournament.description)).toBeInTheDocument(); }); it.each(placeholderTabs)('shows PlaceholderTab on "%s" click', async (tabName) => { render(); + await waitFor(() => { + expect(screen.getByText('Що потрібно зробити?')).toBeInTheDocument(); + }); + await userEvent.click(screen.getByText(tabName)); - expect(screen.getByText('Скоро буде...')).toBeInTheDocument(); + expect(screen.getByText('В розробці...')).toBeInTheDocument(); expect(screen.queryByText('Що потрібно зробити?')).not.toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap b/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap index 2c5b10e..3905f13 100644 --- a/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap +++ b/frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap @@ -3,165 +3,68 @@ exports[`TournamentPage > matches snapshot 1`] = `
    - Фейковий Хедер -
    -
    - Фейковий Хіро з анімацією -
    -
    -
    - - - - -
    + -
    + +
    +

    + Ой, халепа! +

    +

    + Проблемки. Турнір трохи загубився в мережі або щось пішло не так. Але не хвилюйтесь, ми вже намагаємося його знайти! +

    -
    -
    -

    - Що потрібно зробити? -

    -

    - Ваша мета — створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс -

    -
    -
    -

    - - - - - Ключові вимоги: -

    -
      -
    • -
      -

      - створити ядро аналог лінукс, створити ядро аналог лінукс, створити ядро аналог лінукс, -

      -
    • -
    • -
      -

      - створити ядро аналог лінукс, -

      -
    • -
    • -
      -

      - створити ядро аналог лінукс, створити ядро аналог лінукс, -

      -
    • -
    -
    -
    -

    - Стек технологій: -

    -

    - Жодних жорстких обмежень! Всього лиш вимога писати на перфокатрі, та використовуючи два резистора і пачку мівіни змусити це чудо запуститись. -

    -
    - - Перфокарти - - - Два резистора - - - Пачка мівіни - -
    -
    -
    + ID турніру не знайдено +

    -
    + +
    `; From b2555b9404e68bb09faedc7ec4c55340c92df81e Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Mon, 13 Apr 2026 14:22:59 +0300 Subject: [PATCH 155/369] tests: add some new tests for profile page --- frontend/package-lock.json | 214 +++++++++++------- frontend/package.json | 3 +- .../pages/Profile/EditProfileModal.test.tsx | 138 +++++++++++ .../src/pages/Profile/updateProfile.test.tsx | 38 ++++ 4 files changed, 315 insertions(+), 78 deletions(-) create mode 100644 frontend/src/pages/Profile/EditProfileModal.test.tsx create mode 100644 frontend/src/pages/Profile/updateProfile.test.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d34d421..400a18f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.1.4", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -149,7 +150,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -512,7 +512,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -561,7 +560,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1288,7 +1286,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", @@ -1355,7 +1352,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.9", "@firebase/component": "0.7.1", @@ -1371,8 +1367,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.12.1", @@ -1823,7 +1818,6 @@ "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2020,6 +2014,13 @@ "react": ">=18.0.0" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2830,7 +2831,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -2982,7 +2982,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3075,7 +3076,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3086,7 +3086,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3142,7 +3141,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3402,16 +3400,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3420,13 +3418,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3447,9 +3445,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -3460,13 +3458,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -3474,14 +3472,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3490,23 +3488,45 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.4.tgz", + "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.4" + } + }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3520,7 +3540,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3681,7 +3700,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3887,8 +3905,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cva": { "version": "1.0.0-beta.4", @@ -4001,7 +4018,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4177,7 +4195,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4426,6 +4443,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4461,7 +4485,6 @@ "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz", "integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/ai": "2.9.0", "@firebase/analytics": "0.10.20", @@ -4508,9 +4531,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5388,6 +5411,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5476,6 +5500,16 @@ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5512,7 +5546,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5656,7 +5689,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5708,6 +5740,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5723,6 +5756,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5781,7 +5815,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5791,7 +5824,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5804,7 +5836,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5838,14 +5869,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5930,8 +5961,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6106,6 +6136,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6293,6 +6338,16 @@ "dev": true, "license": "MIT" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -6357,7 +6412,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6461,7 +6515,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6532,19 +6585,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6572,10 +6625,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6599,6 +6654,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -6828,7 +6889,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 4dfc656..4180f8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.1.4", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -54,4 +55,4 @@ "vite": "^7.3.1", "vitest": "^4.1.2" } -} \ No newline at end of file +} diff --git a/frontend/src/pages/Profile/EditProfileModal.test.tsx b/frontend/src/pages/Profile/EditProfileModal.test.tsx new file mode 100644 index 0000000..3205432 --- /dev/null +++ b/frontend/src/pages/Profile/EditProfileModal.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useSelector } from 'react-redux'; +import { useMutation } from '@tanstack/react-query'; +import { EditProfileModal } from './EditProfileModal'; +import { store } from '../../store'; + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})); + +vi.mock('../../store', () => ({ + store: { dispatch: vi.fn() }, +})); + +vi.mock('@/firebase', () => ({ + auth: { currentUser: { uid: 'user-123' } }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn(), +})); + +const mockUser = { + uid: 'user-123', + displayName: 'Супер Хакер', + telegram: '@hacker', + github: 'hacker777', + discord: 'hacker#7777', +}; + +describe('EditProfileModal Component', () => { + const mockOnClose = vi.fn(); + let mutationConfig: any; + let mockMutate: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockMutate = vi.fn(); + + vi.mocked(useMutation).mockImplementation((config) => { + mutationConfig = config; + return { + mutate: mockMutate, + isPending: false, + } as any; + }); + + vi.mocked(useSelector).mockReturnValue(mockUser); + }); + + it('does not render anything when isOpen is false', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders correctly and populates form with existing user data', () => { + render(); + + expect(screen.getByDisplayValue('Супер Хакер')).toBeInTheDocument(); + expect(screen.getByDisplayValue('@hacker')).toBeInTheDocument(); + expect(screen.getByDisplayValue('hacker777')).toBeInTheDocument(); + expect(screen.getByDisplayValue('hacker#7777')).toBeInTheDocument(); + }); + + it('updates local state when user types in inputs', () => { + render(); + + const nameInput = screen.getByDisplayValue('Супер Хакер'); + fireEvent.change(nameInput, { target: { value: 'Нове Ім\'я', name: 'full_name' } }); + + expect(nameInput).toHaveValue('Нове Ім\'я'); + }); + + it('calls onClose when close button (x) or cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByText('×')); + expect(mockOnClose).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('Скасувати')); + expect(mockOnClose).toHaveBeenCalledTimes(2); + }); + + it('calls onClose when clicking on the overlay, but NOT inside the modal card', () => { + const { container } = render(); + + const modalCard = container.querySelector('.modal-card'); + fireEvent.click(modalCard!); + expect(mockOnClose).not.toHaveBeenCalled(); + + const overlay = container.querySelector('.modal-overlay'); + fireEvent.click(overlay!); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls mutate with form data on form submit', () => { + const { container } = render(); + + const nameInput = screen.getByDisplayValue('Супер Хакер'); + fireEvent.change(nameInput, { target: { value: 'Лінус Торвальдс', name: 'full_name' } }); + + fireEvent.submit(container.querySelector('form')!); + + expect(mockMutate).toHaveBeenCalledWith({ + full_name: 'Лінус Торвальдс', + telegram: '@hacker', + github: 'hacker777', + discord: 'hacker#7777', + }); + }); + + it('disables submit button and shows loading text while pending', () => { + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + isPending: true, + } as any); + + render(); + + const submitBtn = screen.getByRole('button', { name: /збереження\.\.\./i }); + expect(submitBtn).toBeDisabled(); + }); + + it('dispatches setUser to Redux and closes modal on mutation success', () => { + render(); + + mutationConfig.onSuccess({ + full_name: 'Новий Хакер', + telegram: '@new', + github: 'new', + discord: 'new#1234' + }); + + expect(store.dispatch).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/pages/Profile/updateProfile.test.tsx b/frontend/src/pages/Profile/updateProfile.test.tsx new file mode 100644 index 0000000..bfa8d21 --- /dev/null +++ b/frontend/src/pages/Profile/updateProfile.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { updateProfile } from '@/api/requests/updateProfile'; +import apiClient from '@/api/client'; + +describe('updateProfile API helper', () => { + const mockFirebaseUser = { + getIdToken: vi.fn().mockResolvedValue('mock-jwt-token-123'), + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches token and sends PATCH request with correct headers and data', async () => { + const mockResponse = { data: { success: true, full_name: 'Тестер' } }; + const patchSpy = vi.spyOn(apiClient, 'patch').mockResolvedValue(mockResponse); + + const updateData = { full_name: 'Тестер', telegram: '@tester' }; + + const result = await updateProfile(mockFirebaseUser, updateData); + expect(mockFirebaseUser.getIdToken).toHaveBeenCalledTimes(1); + expect(patchSpy).toHaveBeenCalledWith('/profile/', updateData, { + headers: { + Authorization: 'Bearer mock-jwt-token-123', + }, + }); + + expect(result).toEqual(mockResponse.data); + }); + + it('throws an error if request fails', async () => { + const error = new Error('Network Error'); + + vi.spyOn(apiClient, 'patch').mockRejectedValue(error); + + await expect(updateProfile(mockFirebaseUser, {})).rejects.toThrow('Network Error'); + }); +}); \ No newline at end of file From 5b07059290cf38e78c20b89acbe9990e312fe1f9 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Mon, 13 Apr 2026 14:45:30 +0300 Subject: [PATCH 156/369] test: some tests for AuthPage --- frontend/src/pages/Auth/Auth.test.tsx | 289 ++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 frontend/src/pages/Auth/Auth.test.tsx diff --git a/frontend/src/pages/Auth/Auth.test.tsx b/frontend/src/pages/Auth/Auth.test.tsx new file mode 100644 index 0000000..26ec119 --- /dev/null +++ b/frontend/src/pages/Auth/Auth.test.tsx @@ -0,0 +1,289 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthPage } from './AuthPage'; + +import { + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + updateProfile, + signInWithPopup, +} from 'firebase/auth'; +import { syncUser } from '../../firebase'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock('firebase/auth', () => ({ + signInWithEmailAndPassword: vi.fn(), + createUserWithEmailAndPassword: vi.fn(), + updateProfile: vi.fn(), + signInWithPopup: vi.fn(), + getAuth: vi.fn(), +})); + +vi.mock('../../firebase', () => ({ + auth: {}, + google: {}, + syncUser: vi.fn(), +})); + +vi.mock('@lottiefiles/react-lottie-player', () => ({ + Player: () =>
    Lottie Animation
    , +})); + +vi.mock('../../components/ui', () => ({ + Button: ({ children, isLoading, leftIcon, ...props }: any) => ( + + ), +})); + +describe('AuthPage Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderAuthPage = () => render(); + const submitFormByButtonName = (buttonName: string | RegExp) => { + const button = screen.getByRole('button', { name: buttonName }); + const form = button.closest('form'); + if (form) fireEvent.submit(form); + }; + + //Render & Basic UI Elements + describe('1. Initial Render & UI Elements', () => { + it('renders the registration form by default', () => { + renderAuthPage(); + expect(screen.getByText('Створити акаунт')).toBeInTheDocument(); + expect(screen.getByText('Нікнейм')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Наприклад, izachoc')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Зареєструватись' })).toBeInTheDocument(); + }); + + it('renders Lottie animation and static text content', () => { + renderAuthPage(); + expect(screen.getByTestId('lottie-player')).toBeInTheDocument(); + expect(screen.getByText(/Твоя історія починається тут/i)).toBeInTheDocument(); + }); + + it('renders the Google sign-in button with its icon', () => { + renderAuthPage(); + expect(screen.getByRole('button', { name: /Вхід через Google/i })).toBeInTheDocument(); + expect(screen.getByTestId('left-icon')).toBeInTheDocument(); + }); + }); + + // Mode Switching (Login / Register) + describe('2. Auth Mode Switching', () => { + it('switches to Login mode and updates text headings', async () => { + renderAuthPage(); + fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + + expect(await screen.findByText('З поверненням!')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Увійти' })).toBeInTheDocument(); + }); + + it('hides the Display Name input when switching to Login mode', async () => { + renderAuthPage(); + fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Наприклад, izachoc')).not.toBeInTheDocument(); + }); + }); + + it('displays "Forgot Password?" link exclusively in Login mode', () => { + renderAuthPage(); + const forgotLink = screen.getByText('Забули пароль?'); + expect(forgotLink).toHaveClass('opacity-0 pointer-events-none'); + + fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + expect(forgotLink).toHaveClass('opacity-100'); + }); + }); + + //Password Input + describe('3. Password Input Interaction', () => { + it('toggles password visibility on eye icon click', () => { + renderAuthPage(); + const passwordInput = screen.getByPlaceholderText('Мінімум 8 символів'); + const toggleBtn = passwordInput.nextElementSibling as HTMLButtonElement; + + expect(passwordInput).toHaveAttribute('type', 'password'); + + fireEvent.click(toggleBtn); + expect(passwordInput).toHaveAttribute('type', 'text'); + + fireEvent.click(toggleBtn); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('updates input border color dynamically based on password length', () => { + renderAuthPage(); + const passwordInput = screen.getByPlaceholderText('Мінімум 8 символів'); + + fireEvent.change(passwordInput, { target: { value: '123' } }); + expect(passwordInput).toHaveClass('border-red-500'); + + fireEvent.change(passwordInput, { target: { value: '12345678' } }); + expect(passwordInput).toHaveClass('border-indigo-500'); + }); + }); + + // Zod Validation + describe('4. Form Validation (Zod)', () => { + it('prevents submission and shows errors for empty registration fields', async () => { + renderAuthPage(); + submitFormByButtonName('Зареєструватись'); + + expect(await screen.findByText("Нікнейм обов'язковий (мінімум 2 символи)")).toBeInTheDocument(); + expect(await screen.findByText('Некоректний формат email')).toBeInTheDocument(); + expect(await screen.findByText('Мінімум 8 символів')).toBeInTheDocument(); + expect(createUserWithEmailAndPassword).not.toHaveBeenCalled(); + }); + + it('shows an error if Display Name is shorter than 2 characters', async () => { + renderAuthPage(); + fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'A' } }); + submitFormByButtonName('Зареєструватись'); + + expect(await screen.findByText("Нікнейм обов'язковий (мінімум 2 символи)")).toBeInTheDocument(); + }); + + it('shows an error on invalid email format', async () => { + renderAuthPage(); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'invalid-email' } }); + submitFormByButtonName('Зареєструватись'); + + expect(await screen.findByText('Некоректний формат email')).toBeInTheDocument(); + }); + }); + + // Happy Paths + describe('5. Successful Auth Flows', () => { + it('successfully registers a user, updates profile, and redirects', async () => { + const mockUser = { uid: 'user-777' }; + vi.mocked(createUserWithEmailAndPassword).mockResolvedValue({ user: mockUser } as any); + + renderAuthPage(); + fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'SuperDev' } }); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'super@test.com' } }); + fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: 'strongPass1' } }); + submitFormByButtonName('Зареєструватись'); + + await waitFor(() => { + expect(createUserWithEmailAndPassword).toHaveBeenCalledWith(expect.anything(), 'super@test.com', 'strongPass1'); + expect(updateProfile).toHaveBeenCalledWith(mockUser, { displayName: 'SuperDev' }); + expect(syncUser).toHaveBeenCalledWith(mockUser); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('successfully logs in an existing user and redirects', async () => { + vi.mocked(signInWithEmailAndPassword).mockResolvedValue({} as any); + renderAuthPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'old@user.com' } }); + fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: 'myPassword8' } }); + submitFormByButtonName('Увійти'); + + await waitFor(() => { + expect(signInWithEmailAndPassword).toHaveBeenCalledWith(expect.anything(), 'old@user.com', 'myPassword8'); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + }); + + // Firebase Server Errors + describe('6. Firebase Error Handling', () => { + it('displays a specific error message for "auth/email-already-in-use"', async () => { + vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/email-already-in-use' }); + renderAuthPage(); + + fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'Dev' } }); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'exist@test.com' } }); + fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); + submitFormByButtonName('Зареєструватись'); + + expect(await screen.findByText('Цей email вже використовується.')).toBeInTheDocument(); + }); + + it('displays a specific error message for "auth/wrong-password" during login', async () => { + vi.mocked(signInWithEmailAndPassword).mockRejectedValue({ code: 'auth/wrong-password' }); + renderAuthPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'a@a.com' } }); + fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); + submitFormByButtonName('Увійти'); + + expect(await screen.findByText('Невірний email або пароль.')).toBeInTheDocument(); + }); + + it('displays a generic error message for unknown Firebase errors', async () => { + vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/too-many-requests' }); + renderAuthPage(); + + fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'Dev' } }); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'a@a.com' } }); + fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); + submitFormByButtonName('Зареєструватись'); + + expect(await screen.findByText('Сталася помилка. Спробуйте ще раз.')).toBeInTheDocument(); + }); + + it('clears server error messages when toggling between Login and Register modes', async () => { + vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/email-already-in-use' }); + renderAuthPage(); + + fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'Dev' } }); + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'a@a.com' } }); + fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); + submitFormByButtonName('Зареєструватись'); + + expect(await screen.findByText('Цей email вже використовується.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + expect(screen.queryByText('Цей email вже використовується.')).not.toBeInTheDocument(); + }); + }); + + //Google Sign-In + describe('7. Google Authentication', () => { + it('successfully authenticates via Google popup and redirects', async () => { + vi.mocked(signInWithPopup).mockResolvedValue({} as any); + renderAuthPage(); + + fireEvent.click(screen.getByRole('button', { name: /Вхід через Google/i })); + + await waitFor(() => { + expect(signInWithPopup).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('catches and logs Google Sign-In exceptions without crashing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(signInWithPopup).mockRejectedValue(new Error('Popup closed')); + + renderAuthPage(); + fireEvent.click(screen.getByRole('button', { name: /Вхід через Google/i })); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith("Google Sign-In Error:", expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + }); +}); \ No newline at end of file From 33836c95be36cf6cf68b73136a278eee671df2db Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Mon, 13 Apr 2026 14:12:44 +0100 Subject: [PATCH 157/369] refactor: rewrite task's endpoints --- backend/app/routes/task_options.py | 2 +- backend/app/routes/tasks.py | 90 ++++++++++++++++++++-------- backend/app/schemas/task.py | 15 +++-- backend/app/utils/fsm/__init__.py | 2 +- backend/app/utils/fsm/task_status.py | 11 +++- backend/app/utils/routes/__init__.py | 1 + backend/app/utils/routes/task.py | 29 +++++++++ 7 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 backend/app/utils/routes/task.py diff --git a/backend/app/routes/task_options.py b/backend/app/routes/task_options.py index 4a1e4d8..d06e86a 100644 --- a/backend/app/routes/task_options.py +++ b/backend/app/routes/task_options.py @@ -4,7 +4,7 @@ from app.schemas import TaskRequirementOptionCreate, TaskRequirementOptionPublic from app.dependencies import SessionDep -router = APIRouter(prefix="/task-options", tags=["task options"]) +router = APIRouter(prefix="/task-options", tags=["task-options"]) @router.get( diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index a0a016c..557dc78 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -1,11 +1,12 @@ from fastapi import status, HTTPException from fastapi.routing import APIRouter -from sqlalchemy import select, update +from sqlalchemy import select from app.dependencies import SessionDep from app.models import Task from app.schemas import TaskCreate, TaskUpdate, TaskPublic -from app.utils.fsm import TaskStatus +from app.utils.fsm import TaskStatus, update_tasks_status +from app.utils.routes import get_requirements router = APIRouter(prefix="/tournaments/{tournament_id}/tasks", tags=["tasks"]) @@ -20,49 +21,90 @@ async def get_task(task_id: int, session: SessionDep) -> Task: @router.get("/", response_model=list[TaskPublic], status_code=status.HTTP_200_OK) async def tasks(tournament_id: int, session: SessionDep): + await update_tasks_status(session) statement = select(Task).where(Task.tournament_id == tournament_id) result = await session.execute(statement) return result.scalars().all() @router.get("/{task_id}/", response_model=TaskPublic, status_code=status.HTTP_200_OK) -async def task(task_id: int, session: SessionDep): - return await get_task(task_id, session) +async def task(tournament_id: int, task_id: int, session: SessionDep): + task = await get_task(task_id, session) + + if task.tournament_id != tournament_id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Task does not belong to this tournament", + ) + + TaskStatus(task).update_by_time() + await session.commit() + await session.refresh(task) + + return task + + +@router.get("/active", response_model=TaskPublic, status_code=status.HTTP_200_OK) +async def get_active_task(tournament_id: int, session: SessionDep): + await update_tasks_status(session) + + result = await session.execute( + select(Task).where( + Task.tournament_id == tournament_id, Task.status_id == "active" + ) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="No active task") + + return task -# @router.post("/", response_model=TaskPublic, status_code=status.HTTP_201_CREATED) -# async def create_task(tournament_id: int, task_data: TaskCreate, session: SessionDep): -# task_dict = task_data.model_dump(exclude={"requirements"}) -# new_task = Task(**task_dict) +@router.post("/", response_model=TaskPublic, status_code=status.HTTP_201_CREATED) +async def create_task(tournament_id: int, task_data: TaskCreate, session: SessionDep): + task_dict = task_data.model_dump(exclude={"requirements"}) + new_task = Task(**task_dict, tournament_id=tournament_id, status_id="draft") + requirements = await get_requirements(task_data.requirements, session) + new_task.requirements = requirements -# session.add(new_task) -# await session.commit() -# await session.refresh(new_task) -# return new_task + session.add(new_task) + await session.commit() + await session.refresh(new_task) + return new_task @router.patch("/{task_id}/", response_model=TaskPublic, status_code=status.HTTP_200_OK) -async def update_task(task_id: int, task_data: TaskUpdate, session: SessionDep): - update_data = task_data.model_dump(exclude_unset=True) +async def update_task( + tournament_id: int, task_id: int, task_data: TaskUpdate, session: SessionDep +): + task = await get_task(task_id, session) - if not update_data: + if task.tournament_id != tournament_id: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No fields provided for update", + status.HTTP_400_BAD_REQUEST, + detail="Task does not belong to this tournament", ) - result = await session.execute( - update(Task).where(Task.id == task_id).values(**update_data).returning(Task) - ) - updated_task = result.scalar_one_or_none() + update_data = task_data.model_dump(exclude_unset=True, exclude={"requirements"}) - if not updated_task: + if not update_data and task_data.requirements is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update", ) + for key, value in update_data.items(): + setattr(task, key, value) + + if task_data.requirements is not None: + task.requirements = await get_requirements(task_data.requirements, session) + TaskStatus(task).update_by_time() + await session.commit() - return updated_task + await session.refresh(task) + + return task @router.delete("/{task_id}/", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 4b470f9..1842c30 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -19,6 +19,8 @@ def check_title(cls, value: str): raise ValueError("Title cannot be empty") return value.strip() + +class TaskCreate(TaskBase): @field_validator("start_time") @classmethod def start_not_past(cls, value: datetime): @@ -43,16 +45,12 @@ def check_time_logic(self) -> Self: return self -class TaskCreate(TaskBase): - tournament_id: int - - class TaskUpdate(BaseModel): title: str | None = Field(None, min_length=3) description: str | None = None start_time: datetime | None = None end_time: datetime | None = None - status_id: str | None = None + requirements: list[str] | None = None class TaskPublic(TaskBase): @@ -61,3 +59,10 @@ class TaskPublic(TaskBase): id: int tournament_id: int = Field(..., gt=0) status_id: str = Field(...) + + @field_validator("requirements", mode="before") + @classmethod + def transform_requirements(cls, value): + if isinstance(value, list) and len(value) > 0 and not isinstance(value[0], str): + return [req.name for req in value] + return value diff --git a/backend/app/utils/fsm/__init__.py b/backend/app/utils/fsm/__init__.py index 4347647..e3ea166 100644 --- a/backend/app/utils/fsm/__init__.py +++ b/backend/app/utils/fsm/__init__.py @@ -1,2 +1,2 @@ -from .task_status import TaskStatus +from .task_status import TaskStatus, update_tasks_status from .tournament_status import auto_update_tournament_status, get_status_by_name diff --git a/backend/app/utils/fsm/task_status.py b/backend/app/utils/fsm/task_status.py index 9f5779f..5b21062 100644 --- a/backend/app/utils/fsm/task_status.py +++ b/backend/app/utils/fsm/task_status.py @@ -13,6 +13,7 @@ class TaskStatus(StateMachine): evaluated = State("Evaluated", value="evaluated", final=True) start = draft.to(active) + reset_to_draft = active.to(draft) close = active.to(submission_closed) evaluate = submission_closed.to(evaluated) @@ -28,12 +29,15 @@ def update_by_time(self): end = self.task.end_time.replace(tzinfo=timezone.utc) changed = False + if self.current_state == self.active and now < start: + self.reset_to_draft() + changed = True - if self.current_state == self.draft and now >= start: + elif self.current_state == self.draft and now >= start: self.start() changed = True - if self.current_state == self.active and now > end: + elif self.current_state == self.active and now > end: self.close() changed = True @@ -41,7 +45,8 @@ def update_by_time(self): async def update_tasks_status(session: SessionDep): - result = await session.execute(select(Task)) + statement = select(Task).where(Task.status_id != "evaluated") + result = await session.execute(statement) tasks = result.scalars().all() changed = False diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py index 5bc1889..33235cd 100644 --- a/backend/app/utils/routes/__init__.py +++ b/backend/app/utils/routes/__init__.py @@ -1,4 +1,5 @@ from .dates_logic import validate_dates_on_create, validate_dates_on_update +from .task import get_requirements from .teams import ( get_tournament, check_registration_open, diff --git a/backend/app/utils/routes/task.py b/backend/app/utils/routes/task.py new file mode 100644 index 0000000..7632b25 --- /dev/null +++ b/backend/app/utils/routes/task.py @@ -0,0 +1,29 @@ +from fastapi import status, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import TaskRequirementOption + + +async def get_requirements( + requirement_names: list[str], session: AsyncSession +) -> list[TaskRequirementOption]: + + if not requirement_names: + return [] + + stmt = select(TaskRequirementOption).where( + TaskRequirementOption.name.in_(requirement_names) + ) + result = await session.execute(stmt) + req_options = result.scalars().all() + + if len(req_options) != len(requirement_names): + found_names = {opt.name for opt in req_options} + missing = set(requirement_names) - found_names + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Requirement options not found: {', '.join(missing)}", + ) + + return list(req_options) From 5659963b8cc606680cfd67960416a95f504ba70c Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Mon, 13 Apr 2026 19:47:59 +0300 Subject: [PATCH 158/369] tests: add new tests for Auth page --- frontend/src/pages/Auth/Auth.test.tsx | 302 +++++++++++++++++++------- 1 file changed, 225 insertions(+), 77 deletions(-) diff --git a/frontend/src/pages/Auth/Auth.test.tsx b/frontend/src/pages/Auth/Auth.test.tsx index 26ec119..8242361 100644 --- a/frontend/src/pages/Auth/Auth.test.tsx +++ b/frontend/src/pages/Auth/Auth.test.tsx @@ -1,17 +1,21 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; -import { AuthPage } from './AuthPage'; +import { AuthPage } from './AuthPage'; +import SignOut from './SignOut'; import { signInWithEmailAndPassword, createUserWithEmailAndPassword, updateProfile, signInWithPopup, + signOut, } from 'firebase/auth'; import { syncUser } from '../../firebase'; const mockNavigate = vi.fn(); + vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { @@ -25,6 +29,7 @@ vi.mock('firebase/auth', () => ({ createUserWithEmailAndPassword: vi.fn(), updateProfile: vi.fn(), signInWithPopup: vi.fn(), + signOut: vi.fn(), getAuth: vi.fn(), })); @@ -52,14 +57,13 @@ describe('AuthPage Component', () => { vi.clearAllMocks(); }); - const renderAuthPage = () => render(); - const submitFormByButtonName = (buttonName: string | RegExp) => { - const button = screen.getByRole('button', { name: buttonName }); - const form = button.closest('form'); - if (form) fireEvent.submit(form); + const renderAuthPage = () => { + const user = userEvent.setup(); + render(); + return { user }; }; - //Render & Basic UI Elements + // Render & Basic UI Elements describe('1. Initial Render & UI Elements', () => { it('renders the registration form by default', () => { renderAuthPage(); @@ -80,70 +84,108 @@ describe('AuthPage Component', () => { expect(screen.getByRole('button', { name: /Вхід через Google/i })).toBeInTheDocument(); expect(screen.getByTestId('left-icon')).toBeInTheDocument(); }); + + it('renders Terms and Privacy Policy links', () => { + renderAuthPage(); + expect(screen.getByText('Умовами використання')).toBeInTheDocument(); + expect(screen.getByText('Політикою конфіденційності')).toBeInTheDocument(); + }); + + it('renders inputs with correct default attributes', () => { + renderAuthPage(); + expect(screen.getByPlaceholderText('name@example.com')).toHaveAttribute('type', 'email'); + expect(screen.getByPlaceholderText('Наприклад, izachoc')).toHaveAttribute('type', 'text'); + }); }); // Mode Switching (Login / Register) describe('2. Auth Mode Switching', () => { it('switches to Login mode and updates text headings', async () => { - renderAuthPage(); - fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + const { user } = renderAuthPage(); + await user.click(screen.getByRole('button', { name: 'Вхід' })); expect(await screen.findByText('З поверненням!')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Увійти' })).toBeInTheDocument(); }); it('hides the Display Name input when switching to Login mode', async () => { - renderAuthPage(); - fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + const { user } = renderAuthPage(); + await user.click(screen.getByRole('button', { name: 'Вхід' })); await waitFor(() => { expect(screen.queryByPlaceholderText('Наприклад, izachoc')).not.toBeInTheDocument(); }); }); - it('displays "Forgot Password?" link exclusively in Login mode', () => { - renderAuthPage(); + it('displays "Forgot Password?" link exclusively in Login mode', async () => { + const { user } = renderAuthPage(); const forgotLink = screen.getByText('Забули пароль?'); expect(forgotLink).toHaveClass('opacity-0 pointer-events-none'); - fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); + await user.click(screen.getByRole('button', { name: 'Вхід' })); expect(forgotLink).toHaveClass('opacity-100'); }); + + it('clears firebase error when toggling modes', async () => { + vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/email-already-in-use' }); + const { user } = renderAuthPage(); + + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'Dev'); + await user.type(screen.getByPlaceholderText('name@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); + + expect(await screen.findByText('Цей email вже використовується.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Вхід' })); + expect(screen.queryByText('Цей email вже використовується.')).not.toBeInTheDocument(); + }); }); - //Password Input + // Password Input Interaction describe('3. Password Input Interaction', () => { - it('toggles password visibility on eye icon click', () => { - renderAuthPage(); + it('toggles password visibility on eye icon click', async () => { + const { user } = renderAuthPage(); const passwordInput = screen.getByPlaceholderText('Мінімум 8 символів'); const toggleBtn = passwordInput.nextElementSibling as HTMLButtonElement; expect(passwordInput).toHaveAttribute('type', 'password'); - fireEvent.click(toggleBtn); + await user.click(toggleBtn); expect(passwordInput).toHaveAttribute('type', 'text'); - fireEvent.click(toggleBtn); + await user.click(toggleBtn); expect(passwordInput).toHaveAttribute('type', 'password'); }); - it('updates input border color dynamically based on password length', () => { - renderAuthPage(); + it('updates input border color dynamically based on password length', async () => { + const { user } = renderAuthPage(); const passwordInput = screen.getByPlaceholderText('Мінімум 8 символів'); - fireEvent.change(passwordInput, { target: { value: '123' } }); + await user.type(passwordInput, '123'); expect(passwordInput).toHaveClass('border-red-500'); - fireEvent.change(passwordInput, { target: { value: '12345678' } }); + await user.type(passwordInput, '45678'); expect(passwordInput).toHaveClass('border-indigo-500'); }); + + it('retains typed password when toggling visibility', async () => { + const { user } = renderAuthPage(); + const passwordInput = screen.getByPlaceholderText('Мінімум 8 символів'); + const toggleBtn = passwordInput.nextElementSibling as HTMLButtonElement; + + await user.type(passwordInput, 'secret123'); + await user.click(toggleBtn); + + expect(passwordInput).toHaveValue('secret123'); + }); }); // Zod Validation describe('4. Form Validation (Zod)', () => { it('prevents submission and shows errors for empty registration fields', async () => { - renderAuthPage(); - submitFormByButtonName('Зареєструватись'); + const { user } = renderAuthPage(); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); expect(await screen.findByText("Нікнейм обов'язковий (мінімум 2 символи)")).toBeInTheDocument(); expect(await screen.findByText('Некоректний формат email')).toBeInTheDocument(); @@ -152,20 +194,53 @@ describe('AuthPage Component', () => { }); it('shows an error if Display Name is shorter than 2 characters', async () => { - renderAuthPage(); - fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'A' } }); - submitFormByButtonName('Зареєструватись'); + const { user } = renderAuthPage(); + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'A'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); expect(await screen.findByText("Нікнейм обов'язковий (мінімум 2 символи)")).toBeInTheDocument(); }); - it('shows an error on invalid email format', async () => { + it('rejects display name with only whitespaces', async () => { renderAuthPage(); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'invalid-email' } }); - submitFormByButtonName('Зареєструватись'); + const nameInput = screen.getByPlaceholderText('Наприклад, izachoc'); + + fireEvent.change(nameInput, { target: { value: ' ' } }); + + const submitBtn = screen.getByRole('button', { name: /зареєструватись/i }); + fireEvent.click(submitBtn); + + expect(await screen.findByText(/нікнейм обов'язковий/i)).toBeInTheDocument(); + }); + + it('shows an error on invalid email format', async () => { + const { user } = renderAuthPage(); + await user.type(screen.getByPlaceholderText('name@example.com'), 'invalid-email'); + const form = screen.getByRole('button', { name: 'Зареєструватись' }).closest('form'); + fireEvent.submit(form!); expect(await screen.findByText('Некоректний формат email')).toBeInTheDocument(); }); + + it('shows error for exactly 7 characters in password', async () => { + const { user } = renderAuthPage(); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '1234567'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); + + expect(await screen.findByText('Мінімум 8 символів')).toBeInTheDocument(); + }); + + it('accepts exactly 8 characters in password without errors', async () => { + const { user } = renderAuthPage(); + await user.click(screen.getByRole('button', { name: 'Вхід' })); + await user.type(screen.getByPlaceholderText('name@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Увійти' })); + + await waitFor(() => { + expect(screen.queryByText('Мінімум 8 символів')).not.toBeInTheDocument(); + }); + }); }); // Happy Paths @@ -174,11 +249,11 @@ describe('AuthPage Component', () => { const mockUser = { uid: 'user-777' }; vi.mocked(createUserWithEmailAndPassword).mockResolvedValue({ user: mockUser } as any); - renderAuthPage(); - fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'SuperDev' } }); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'super@test.com' } }); - fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: 'strongPass1' } }); - submitFormByButtonName('Зареєструватись'); + const { user } = renderAuthPage(); + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'SuperDev'); + await user.type(screen.getByPlaceholderText('name@example.com'), 'super@test.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), 'strongPass1'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); await waitFor(() => { expect(createUserWithEmailAndPassword).toHaveBeenCalledWith(expect.anything(), 'super@test.com', 'strongPass1'); @@ -190,12 +265,12 @@ describe('AuthPage Component', () => { it('successfully logs in an existing user and redirects', async () => { vi.mocked(signInWithEmailAndPassword).mockResolvedValue({} as any); - renderAuthPage(); + const { user } = renderAuthPage(); - fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'old@user.com' } }); - fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: 'myPassword8' } }); - submitFormByButtonName('Увійти'); + await user.click(screen.getByRole('button', { name: 'Вхід' })); + await user.type(screen.getByPlaceholderText('name@example.com'), 'old@user.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), 'myPassword8'); + await user.click(screen.getByRole('button', { name: 'Увійти' })); await waitFor(() => { expect(signInWithEmailAndPassword).toHaveBeenCalledWith(expect.anything(), 'old@user.com', 'myPassword8'); @@ -204,67 +279,122 @@ describe('AuthPage Component', () => { }); }); + // Loading States + describe('6. Loading States', () => { + it('disables submit button and shows loading state during login', async () => { + let resolvePromise: (value: any) => void; + const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); + vi.mocked(signInWithEmailAndPassword).mockReturnValue(pendingPromise as any); + + const { user } = renderAuthPage(); + + await user.click(screen.getByRole('button', { name: 'Вхід' })); + await user.type(screen.getByPlaceholderText('name@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + + const submitBtn = screen.getByRole('button', { name: 'Увійти' }); + await user.click(submitBtn); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(submitBtn).toBeDisabled(); + + resolvePromise!({}); + }); + + it('disables submit button and shows loading state during registration', async () => { + let resolvePromise: (value: any) => void; + const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); + vi.mocked(createUserWithEmailAndPassword).mockReturnValue(pendingPromise as any); + + const { user } = renderAuthPage(); + + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'Dev'); + await user.type(screen.getByPlaceholderText('name@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + + const submitBtn = screen.getByRole('button', { name: 'Зареєструватись' }); + await user.click(submitBtn); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(submitBtn).toBeDisabled(); + + resolvePromise!({}); + }); + }); + // Firebase Server Errors - describe('6. Firebase Error Handling', () => { - it('displays a specific error message for "auth/email-already-in-use"', async () => { + describe('7. Firebase Error Handling', () => { + it('displays error message for "auth/email-already-in-use"', async () => { vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/email-already-in-use' }); - renderAuthPage(); + const { user } = renderAuthPage(); - fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'Dev' } }); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'exist@test.com' } }); - fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); - submitFormByButtonName('Зареєструватись'); + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'Dev'); + await user.type(screen.getByPlaceholderText('name@example.com'), 'exist@test.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); expect(await screen.findByText('Цей email вже використовується.')).toBeInTheDocument(); }); - it('displays a specific error message for "auth/wrong-password" during login', async () => { + it('displays error message for "auth/wrong-password" during login', async () => { vi.mocked(signInWithEmailAndPassword).mockRejectedValue({ code: 'auth/wrong-password' }); - renderAuthPage(); + const { user } = renderAuthPage(); + + await user.click(screen.getByRole('button', { name: 'Вхід' })); + await user.type(screen.getByPlaceholderText('name@example.com'), 'a@a.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Увійти' })); + + expect(await screen.findByText('Невірний email або пароль.')).toBeInTheDocument(); + }); + + it('displays error message for "auth/invalid-credential" during login', async () => { + vi.mocked(signInWithEmailAndPassword).mockRejectedValue({ code: 'auth/invalid-credential' }); + const { user } = renderAuthPage(); - fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'a@a.com' } }); - fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); - submitFormByButtonName('Увійти'); + await user.click(screen.getByRole('button', { name: 'Вхід' })); + await user.type(screen.getByPlaceholderText('name@example.com'), 'a@a.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Увійти' })); expect(await screen.findByText('Невірний email або пароль.')).toBeInTheDocument(); }); it('displays a generic error message for unknown Firebase errors', async () => { vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/too-many-requests' }); - renderAuthPage(); + const { user } = renderAuthPage(); - fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'Dev' } }); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'a@a.com' } }); - fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); - submitFormByButtonName('Зареєструватись'); + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'Dev'); + await user.type(screen.getByPlaceholderText('name@example.com'), 'a@a.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); expect(await screen.findByText('Сталася помилка. Спробуйте ще раз.')).toBeInTheDocument(); }); - it('clears server error messages when toggling between Login and Register modes', async () => { - vi.mocked(createUserWithEmailAndPassword).mockRejectedValue({ code: 'auth/email-already-in-use' }); - renderAuthPage(); - - fireEvent.change(screen.getByPlaceholderText('Наприклад, izachoc'), { target: { value: 'Dev' } }); - fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'a@a.com' } }); - fireEvent.change(screen.getByPlaceholderText('Мінімум 8 символів'), { target: { value: '12345678' } }); - submitFormByButtonName('Зареєструватись'); + it('handles unexpected errors gracefully during registration profile update', async () => { + const mockUser = { uid: 'user-error' }; + vi.mocked(createUserWithEmailAndPassword).mockResolvedValue({ user: mockUser } as any); + vi.mocked(updateProfile).mockRejectedValue(new Error('Profile update failed')); + + const { user } = renderAuthPage(); - expect(await screen.findByText('Цей email вже використовується.')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Наприклад, izachoc'), 'Dev'); + await user.type(screen.getByPlaceholderText('name@example.com'), 'a@a.com'); + await user.type(screen.getByPlaceholderText('Мінімум 8 символів'), '12345678'); + await user.click(screen.getByRole('button', { name: 'Зареєструватись' })); - fireEvent.click(screen.getByRole('button', { name: 'Вхід' })); - expect(screen.queryByText('Цей email вже використовується.')).not.toBeInTheDocument(); + expect(await screen.findByText('Сталася помилка. Спробуйте ще раз.')).toBeInTheDocument(); }); }); - //Google Sign-In - describe('7. Google Authentication', () => { + // Google Sign-In + describe('8. Google Authentication', () => { it('successfully authenticates via Google popup and redirects', async () => { vi.mocked(signInWithPopup).mockResolvedValue({} as any); - renderAuthPage(); + const { user } = renderAuthPage(); - fireEvent.click(screen.getByRole('button', { name: /Вхід через Google/i })); + await user.click(screen.getByRole('button', { name: /Вхід через Google/i })); await waitFor(() => { expect(signInWithPopup).toHaveBeenCalled(); @@ -276,8 +406,8 @@ describe('AuthPage Component', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.mocked(signInWithPopup).mockRejectedValue(new Error('Popup closed')); - renderAuthPage(); - fireEvent.click(screen.getByRole('button', { name: /Вхід через Google/i })); + const { user } = renderAuthPage(); + await user.click(screen.getByRole('button', { name: /Вхід через Google/i })); await waitFor(() => { expect(consoleSpy).toHaveBeenCalledWith("Google Sign-In Error:", expect.any(Error)); @@ -286,4 +416,22 @@ describe('AuthPage Component', () => { consoleSpy.mockRestore(); }); }); +}); + +describe('SignOut Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderSignOut = () => render(); + + it('calls signOut and redirects to home on successful logout', async () => { + vi.mocked(signOut).mockResolvedValue(undefined); + renderSignOut(); + + await waitFor(() => { + expect(signOut).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); + }); + }); }); \ No newline at end of file From 59755999615bec4caf8c82c496552b81da336720 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 13 Apr 2026 21:34:11 +0300 Subject: [PATCH 159/369] feat: implment notifications using websockets --- backend/app/__init__.py | 32 +++++++++ backend/app/config.py | 5 ++ backend/app/dependencies/current_user.py | 9 ++- backend/app/main.py | 28 -------- backend/app/util.py | 6 +- backend/app/websockets.py | 24 +++++++ backend/main.py | 4 ++ backend/tests/conftest.py | 2 +- backend/tests/routes/test_profile.py | 2 +- backend/tests/routes/test_role_request.py | 2 +- frontend/package-lock.json | 88 ++++++++++++++++++++++- frontend/package.json | 3 +- frontend/src/App.tsx | 30 ++++++++ frontend/vite-env.d.ts | 1 + requirements.txt | 5 ++ 15 files changed, 204 insertions(+), 37 deletions(-) delete mode 100644 backend/app/main.py create mode 100644 backend/app/websockets.py create mode 100644 backend/main.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e69de29..e9ea3a6 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +import app.routes.tournaments as tournaments +import app.routes.users as users +import app.routes.profile as profile +import app.routes.role_requests as role_requests +import app.routes.roles as roles +from fastapi.middleware.cors import CORSMiddleware +from .config import settings +import socketio +import uvicorn + +app = FastAPI() +app.state.user_websocket_sessions = {} + +from .websockets import * + +socket_app = socketio.ASGIApp(sio, other_asgi_app=app) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(tournaments.router) +app.include_router(users.router) +app.include_router(profile.router) +app.include_router(role_requests.router) +app.include_router(roles.router) + diff --git a/backend/app/config.py b/backend/app/config.py index 60c6741..11ee8c7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,6 +5,11 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file="../.env", extra='ignore') SECRET_KEY: str = os.getenv("SECRET_KEY") + # TODO: Move this to a config file + CORS_ORIGINS: list[str] = [ + "http://localhost", + "http://localhost:5173", + ] SQLALCHEMY_DATABASE_URI: str = os.getenv("SQLALCHEMY_DATABASE_URI") ROLE_REQUEST_NOTIFICATION_MESSAGE: str = ''' A user $user requested a role $role. Do you approve this request? diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py index b165849..05fcb4d 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -16,10 +16,15 @@ async def get_current_user( session: SessionDep, - token: Annotated[HTTPBearer, Depends(HTTPBearer())] + token: Annotated[HTTPBearer | str, Depends(HTTPBearer())] ) -> User: try: - token = auth.verify_id_token(token.credentials, firebase) + # If using http + if hasattr(token, 'credentials'): + token = auth.verify_id_token(token.credentials, firebase) + # If using websockets + else: + token = auth.verify_id_token(token, firebase) u: auth.UserRecord = auth.get_user_by_email(token['email']) try: user = await get_user(u.uid, session) diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index 87efcfc..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastapi import FastAPI -import app.routes.tournaments as tournaments -import app.routes.users as users -import app.routes.profile as profile -import app.routes.role_requests as role_requests -import app.routes.roles as roles -from fastapi.middleware.cors import CORSMiddleware - -app = FastAPI() - -origins = [ - "http://localhost", - "http://localhost:5173", -] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(tournaments.router) -app.include_router(users.router) -app.include_router(profile.router) -app.include_router(role_requests.router) -app.include_router(roles.router) diff --git a/backend/app/util.py b/backend/app/util.py index 6314b61..f67e08b 100644 --- a/backend/app/util.py +++ b/backend/app/util.py @@ -1,7 +1,9 @@ from app.dependencies import SessionDep -from app.schemas import NotificationCreate +from app.schemas import NotificationCreate, NotificationPublic from app.models import Notification from string import Template +from .websockets import sio +from app import app async def send_notification(notification: NotificationCreate, session: SessionDep, **kwargs) -> Notification: notification = NotificationCreate.model_validate(notification) @@ -10,6 +12,8 @@ async def send_notification(notification: NotificationCreate, session: SessionDe except ValueError: raise ValueError('Notification body placeholder was not provided!') notification = Notification(**notification.model_dump()) + user_sid = app.state.user_websocket_sessions[notification.user_id]['sid'] + sio.emit('on_notification', NotificationPublic.model_validate(notification).model_dump(), user_sid) session.add(notification) await session.commit() return notification \ No newline at end of file diff --git a/backend/app/websockets.py b/backend/app/websockets.py new file mode 100644 index 0000000..12ba64f --- /dev/null +++ b/backend/app/websockets.py @@ -0,0 +1,24 @@ +import socketio +from app.config import settings +from app.dependencies import get_current_user, get_session +from app import app + +sio = socketio.AsyncServer( + cors_allowed_origins=settings.CORS_ORIGINS, + async_mode='asgi' +) + +@sio.event +async def connect(sid, environ, auth: str): + async for session in get_session(): + user = await get_current_user(session, auth['token']) + app.state.user_websocket_sessions[user.id] = { + 'sid': sid + } + +@sio.event +async def disconnect(sid): + for k, v in app.state.user_websocket_sessions.items(): + if v['sid'] == sid: + app.state.user_websocket_sessions.pop(k) + return \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e58b03e --- /dev/null +++ b/backend/main.py @@ -0,0 +1,4 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run("app:socket_app", host="127.0.0.1", port=8000, reload=True) \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 363734a..eb25cbb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from app.models import Base -from app.main import app +from app import app from app.db import get_session diff --git a/backend/tests/routes/test_profile.py b/backend/tests/routes/test_profile.py index 0132be2..23d2796 100644 --- a/backend/tests/routes/test_profile.py +++ b/backend/tests/routes/test_profile.py @@ -1,7 +1,7 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import selectinload -from app.main import app +from app import app from app.dependencies import get_current_user from app.models import User from tests.factories import UserFactory diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index f9683c5..dabc61c 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -1,7 +1,7 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import selectinload -from app.main import app +from app import app from app.dependencies import get_current_user from app.routes.role_requests import get_admin_user from app.models import RoleRequest, User diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d34d421..7dc092a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", + "socket.io-client": "^4.8.3", "tailwindcss": "^4.2.1", "zod": "^4.3.6" }, @@ -2475,6 +2476,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3928,7 +3935,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4030,6 +4036,28 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5480,7 +5508,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6106,6 +6133,34 @@ "dev": true, "license": "ISC" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.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", @@ -6750,6 +6805,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -6767,6 +6843,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4dfc656..913fb62 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", + "socket.io-client": "^4.8.3", "tailwindcss": "^4.2.1", "zod": "^4.3.6" }, @@ -54,4 +55,4 @@ "vite": "^7.3.1", "vitest": "^4.1.2" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d63448..292df19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,8 +16,38 @@ import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; import { Toaster } from 'react-hot-toast'; import { AuthPage } from "./pages/Auth/AuthPage"; import SignOut from "./pages/Auth/SignOut"; +import { useNotificationsSocket } from "./hooks/useNotificationsSocket"; +import { io, Socket } from 'socket.io-client'; +import { auth } from "./firebase"; +import { useEffect, useState } from "react"; export const App = () => { + const [socket, setSocket] = useState(); + useEffect(() => { + const initSocket = async () => { + const token = await auth.currentUser?.getIdToken(); + console.log(token) + if (token) { + const s = io(import.meta.env.VITE_SOCKETIO_SERVER_URL, { + auth: { token } + }); + + setSocket(s); + + return () => { + s.disconnect(); + }; + } + }; + + initSocket(); + }, []); + useNotificationsSocket(socket); + + if (!socket) { + return
    Connecting to notifications...
    ; + } + return ( <> diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index 8a29bfe..4b0b27e 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -4,6 +4,7 @@ interface ViteTypeOptions { interface ImportMetaEnv { readonly VITE_BACKEND_URL: string; + readonly VITE_SOCKETIO_SERVER_URL: string; } interface ImportMeta { diff --git a/requirements.txt b/requirements.txt index af9443d..4ab4734 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ annotated-types==0.7.0 anyio==4.12.1 async-timeout==5.0.1 asyncpg==0.31.0 +bidict==0.23.1 CacheControl==0.14.4 certifi==2026.1.4 cffi==2.0.0 @@ -66,7 +67,9 @@ pytest-asyncio==1.3.0 pytest-mock==3.15.1 pytest_async==0.1.1 python-dotenv==1.2.1 +python-engineio==4.13.1 python-multipart==0.0.22 +python-socketio==5.16.1 PyYAML==6.0.3 requests==2.32.5 rich==14.3.2 @@ -74,6 +77,7 @@ rich-toolkit==0.19.4 rignore==0.7.6 sentry-sdk==2.53.0 shellingham==1.5.4 +simple-websocket==1.1.0 SQLAlchemy==2.0.46 starlette==0.52.1 tomli==2.4.0 @@ -85,3 +89,4 @@ uvicorn==0.41.0 uvloop==0.22.1 watchfiles==1.1.1 websockets==16.0 +wsproto==1.3.2 From 4d3a89cc9f58fc9962de40b5417e2661a3139764 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 13 Apr 2026 21:56:56 +0300 Subject: [PATCH 160/369] refactor(routes): send notifications after the request was approved/rejected --- backend/alembic/versions/eb2f31fb86a1_.py | 36 ++++++++++++++++++++ backend/app/__init__.py | 1 - backend/app/config.py | 4 +-- backend/app/models/notification.py | 2 +- backend/app/routes/role_requests.py | 12 +++++-- backend/app/util.py | 9 ++--- backend/tests/routes/test_role_request.py | 2 +- frontend/src/App.tsx | 3 +- frontend/src/hooks/useNotificationsSocket.ts | 9 ++--- 9 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 backend/alembic/versions/eb2f31fb86a1_.py diff --git a/backend/alembic/versions/eb2f31fb86a1_.py b/backend/alembic/versions/eb2f31fb86a1_.py new file mode 100644 index 0000000..c03c636 --- /dev/null +++ b/backend/alembic/versions/eb2f31fb86a1_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: eb2f31fb86a1 +Revises: 5b67e3a0a3b6 +Create Date: 2026-04-13 21:50:54.720031 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'eb2f31fb86a1' +down_revision: Union[str, Sequence[str], None] = '5b67e3a0a3b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('notifications_body_key'), type_='unique') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('notifications_body_key'), ['body'], postgresql_nulls_not_distinct=False) + + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e9ea3a6..4e1a27c 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -7,7 +7,6 @@ from fastapi.middleware.cors import CORSMiddleware from .config import settings import socketio -import uvicorn app = FastAPI() app.state.user_websocket_sessions = {} diff --git a/backend/app/config.py b/backend/app/config.py index 11ee8c7..395e69b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,8 +17,8 @@ class Settings(BaseSettings): ROLE_REQUEST_APPROVED_MESSAGE: str = ''' Your role request was approved. The role $role was granted to you! ''' - ROLE_REQUEST_DIS_MESSAGE: str = ''' - Your role request was approved. You were not granted the role $role + ROLE_REQUEST_REJECTED_MESSAGE: str = ''' + Your role request was rejected. You were not granted the role $role ''' diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 9769d7b..231d189 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -9,7 +9,7 @@ class Notification(Base, PKMixin): __tablename__ = "notifications" - body: Mapped[str] = mapped_column(String(4096), unique=True) + body: Mapped[str] = mapped_column(String(4096)) user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) user: Mapped["User"] = relationship( diff --git a/backend/app/routes/role_requests.py b/backend/app/routes/role_requests.py index aff7a18..e6bb991 100644 --- a/backend/app/routes/role_requests.py +++ b/backend/app/routes/role_requests.py @@ -5,7 +5,9 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from app.models import RoleRequest, User, Role, RoleRequestInfo -from app.schemas import RoleRequestPublic, RoleRequestCreate, UserPublic +from app.schemas import RoleRequestPublic, RoleRequestCreate, UserPublic, NotificationCreate +from app.config import settings +from app.util import send_notification router = APIRouter(prefix='/role-requests', tags=['role requests']) @@ -83,12 +85,16 @@ async def approve_request(request_id: int, request: RoleRequestDep, session: Ses await session.delete(request) await session.commit() await session.refresh(request.user) + notification = NotificationCreate(body=settings.ROLE_REQUEST_APPROVED_MESSAGE, user_id=request.user.id) + await send_notification(notification, session, role=request.role.name) return request.user -@router.post('/{request_id}/disapprove/', response_model=UserPublic) -async def disapprove_request(request_id: int, request: RoleRequestDep, session: SessionDep): +@router.post('/{request_id}/reject/', response_model=UserPublic) +async def reject_request(request_id: int, request: RoleRequestDep, session: SessionDep): await session.delete(request) await session.commit() + notification = NotificationCreate(body=settings.ROLE_REQUEST_REJECTED_MESSAGE, user_id=request.user.id) + await send_notification(notification, session, role=request.role.name) return request.user @router.delete('/{request_id}/', diff --git a/backend/app/util.py b/backend/app/util.py index f67e08b..ebc9bad 100644 --- a/backend/app/util.py +++ b/backend/app/util.py @@ -2,10 +2,10 @@ from app.schemas import NotificationCreate, NotificationPublic from app.models import Notification from string import Template -from .websockets import sio -from app import app async def send_notification(notification: NotificationCreate, session: SessionDep, **kwargs) -> Notification: + from .websockets import sio + from app import app notification = NotificationCreate.model_validate(notification) try: notification.body = str(Template(notification.body).substitute(**kwargs)) @@ -13,7 +13,8 @@ async def send_notification(notification: NotificationCreate, session: SessionDe raise ValueError('Notification body placeholder was not provided!') notification = Notification(**notification.model_dump()) user_sid = app.state.user_websocket_sessions[notification.user_id]['sid'] - sio.emit('on_notification', NotificationPublic.model_validate(notification).model_dump(), user_sid) session.add(notification) await session.commit() - return notification \ No newline at end of file + await session.refresh(notification) + await sio.emit('notification', NotificationPublic.model_validate(notification).model_dump(), user_sid) + return notification diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index dabc61c..5dddc9b 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -65,7 +65,7 @@ async def test_role_request_disapproval(create, client, db_session): db_session.add(r) await db_session.commit() await db_session.refresh(r) - resp = await client.post(f'/role-requests/{r.id}/disapprove/') + resp = await client.post(f'/role-requests/{r.id}/reject/') assert resp.status_code == 200 assert len(user.roles) == 0 s = select(RoleRequest) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 292df19..733fc4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,11 +22,10 @@ import { auth } from "./firebase"; import { useEffect, useState } from "react"; export const App = () => { - const [socket, setSocket] = useState(); + const [socket, setSocket] = useState(null); useEffect(() => { const initSocket = async () => { const token = await auth.currentUser?.getIdToken(); - console.log(token) if (token) { const s = io(import.meta.env.VITE_SOCKETIO_SERVER_URL, { auth: { token } diff --git a/frontend/src/hooks/useNotificationsSocket.ts b/frontend/src/hooks/useNotificationsSocket.ts index b637c77..5f249d5 100644 --- a/frontend/src/hooks/useNotificationsSocket.ts +++ b/frontend/src/hooks/useNotificationsSocket.ts @@ -1,13 +1,13 @@ import { useEffect } from "react"; import { useDispatch } from "react-redux"; import { addNotification } from "../slices/notifications"; +import type { Socket } from "socket.io-client"; -export const useNotificationsSocket = (socket: any) => { +export const useNotificationsSocket = (socket: Socket | null) => { const dispatch = useDispatch(); useEffect(() => { - if (!socket) return; const handleNewNotification = (data: { body: string }) => { dispatch( addNotification({ @@ -16,11 +16,12 @@ export const useNotificationsSocket = (socket: any) => { }) ); }; + if (!socket) return; - socket.on("on_notification", handleNewNotification); + socket.on("notification", handleNewNotification); return () => { - socket.off("on_notification", handleNewNotification); + socket.off("notification", handleNewNotification); }; }, [dispatch, socket]); }; \ No newline at end of file From 01e107592bbc87d59ed2ddf8a1a5b817e0fbff46 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Mon, 13 Apr 2026 22:09:08 +0300 Subject: [PATCH 161/369] test: make role request approval/rejection and sending notification tests pass --- backend/app/util.py | 15 +++++++++++++-- backend/tests/routes/test_role_request.py | 15 +++++++++++++-- backend/tests/test_models.py | 11 ----------- backend/tests/test_util.py | 11 +++++++++-- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/backend/app/util.py b/backend/app/util.py index ebc9bad..a134d22 100644 --- a/backend/app/util.py +++ b/backend/app/util.py @@ -1,6 +1,8 @@ from app.dependencies import SessionDep from app.schemas import NotificationCreate, NotificationPublic -from app.models import Notification +from app.models import Notification, User +from sqlalchemy import select +from sqlalchemy.orm import selectinload from string import Template async def send_notification(notification: NotificationCreate, session: SessionDep, **kwargs) -> Notification: @@ -16,5 +18,14 @@ async def send_notification(notification: NotificationCreate, session: SessionDe session.add(notification) await session.commit() await session.refresh(notification) - await sio.emit('notification', NotificationPublic.model_validate(notification).model_dump(), user_sid) + user_result = await session.execute( + select(User).options(selectinload(User.roles)).where(User.id == notification.user_id) + ) + user = user_result.scalar_one() + payload = NotificationPublic.model_validate({ + 'body': notification.body, + 'user_id': notification.user_id, + 'user': user, + }) + await sio.emit('notification', payload.model_dump(), user_sid) return notification diff --git a/backend/tests/routes/test_role_request.py b/backend/tests/routes/test_role_request.py index 5dddc9b..7b159e7 100644 --- a/backend/tests/routes/test_role_request.py +++ b/backend/tests/routes/test_role_request.py @@ -6,9 +6,10 @@ from app.routes.role_requests import get_admin_user from app.models import RoleRequest, User from tests.factories import UserFactory, RoleFactory +from app.websockets import sio @pytest.mark.asyncio -async def test_role_request_and_approval(create, client, db_session): +async def test_role_request_and_approval(create, client, db_session, mocker): user = await create(UserFactory) stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) user = (await db_session.execute(stmt)).unique().scalar_one() @@ -26,10 +27,15 @@ async def test_role_request_and_approval(create, client, db_session): req_id = resp.json()['id'] s = select(RoleRequest) assert len((await db_session.execute(s)).scalars().all()) == 1 + app.state.user_websocket_sessions[user.id] = { + 'sid': 'test' + } + spy = mocker.spy(sio, 'emit') resp = await client.post(f'/role-requests/{req_id}/approve/') assert resp.status_code == 200 assert len(resp.json()['roles']) == 1 assert len((await db_session.execute(s)).scalars().all()) == 0 + assert spy.call_count == 1 app.dependency_overrides.pop(get_current_user) app.dependency_overrides.pop(get_admin_user) @@ -55,7 +61,7 @@ async def test_create_role_request_exists(create, client, db_session): app.dependency_overrides.pop(get_current_user) @pytest.mark.asyncio -async def test_role_request_disapproval(create, client, db_session): +async def test_role_request_disapproval(create, client, db_session, mocker): user = await create(UserFactory) stmt = select(User).where(User.id == user.id).options(selectinload(User.roles)) user = (await db_session.execute(stmt)).unique().scalar_one() @@ -65,9 +71,14 @@ async def test_role_request_disapproval(create, client, db_session): db_session.add(r) await db_session.commit() await db_session.refresh(r) + app.state.user_websocket_sessions[user.id] = { + 'sid': 'test' + } + spy = mocker.spy(sio, 'emit') resp = await client.post(f'/role-requests/{r.id}/reject/') assert resp.status_code == 200 assert len(user.roles) == 0 + assert spy.call_count == 1 s = select(RoleRequest) assert len((await db_session.execute(s)).scalars().all()) == 0 app.dependency_overrides.pop(get_current_user) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index a0c77ca..7630993 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -584,17 +584,6 @@ async def test_user_notifications_relationship(db_session, create): assert len(db_user.notifications) == 2 - -async def test_notification_body_unique(db_session, create): - notification = await create(NotificationFactory) - - with pytest.raises(IntegrityError): - await create( - NotificationFactory, - body=notification.body, - ) - - async def test_notification_user_relationship(db_session, create): notification = await create(NotificationFactory) diff --git a/backend/tests/test_util.py b/backend/tests/test_util.py index 0b4093c..0c55f71 100644 --- a/backend/tests/test_util.py +++ b/backend/tests/test_util.py @@ -3,10 +3,17 @@ from app.schemas import NotificationCreate from .factories import UserFactory, RoleFactory from app.config import settings +from app import app +from app.websockets import sio @pytest.mark.asyncio -async def test_send_role_request_notification(db_session, create): +async def test_send_role_request_notification(db_session, create, mocker): user = await create(UserFactory) role = await create(RoleFactory) notification = NotificationCreate(body=settings.ROLE_REQUEST_NOTIFICATION_MESSAGE, user_id=user.id) - await send_notification(notification, db_session, user=user.full_name, role=role.name) \ No newline at end of file + app.state.user_websocket_sessions[user.id] = { + 'sid': 'test' + } + spy = mocker.spy(sio, 'emit') + await send_notification(notification, db_session, user=user.full_name, role=role.name) + assert spy.call_count == 1 \ No newline at end of file From 89d44eb9cb05ca4bea1c46a9eb18778077e720b5 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 14 Apr 2026 20:13:57 +0300 Subject: [PATCH 162/369] feat(ForgotPassword): Add styles for page password reset --- frontend/package-lock.json | 32 ++-- frontend/src/pages/Auth/ForgotPassword.tsx | 176 ++++++++++++++++++++- 2 files changed, 185 insertions(+), 23 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 25c8bba..26600a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6163,6 +6163,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -6191,21 +6206,6 @@ "node": ">=10.0.0" } }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6991,4 +6991,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/src/pages/Auth/ForgotPassword.tsx b/frontend/src/pages/Auth/ForgotPassword.tsx index fe5ae6b..ff9041f 100644 --- a/frontend/src/pages/Auth/ForgotPassword.tsx +++ b/frontend/src/pages/Auth/ForgotPassword.tsx @@ -1,11 +1,173 @@ -import { ForgotPasswordAuthScreen } from "@firebase-oss/ui-react" -import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { motion, AnimatePresence } from "framer-motion"; +import { ArrowLeft, KeyRound, Mail, CheckCircle2 } from "lucide-react"; +import { Link } from "react-router-dom"; +import { sendPasswordResetEmail } from "firebase/auth"; +import type { FirebaseError } from "firebase/app"; -const ForgotPassword = () => { - const navigate = useNavigate(); - return navigate('/auth/')}> +import { auth } from "../../firebase"; +import { Button } from "../../components/ui"; - ; -} +const resetSchema = z.object({ + email: z.string().email("Некоректний формат email"), +}); + +type ResetFormData = z.infer; + +export const ForgotPassword = () => { + const [isSuccess, setIsSuccess] = useState(false); + const [firebaseError, setFirebaseError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(resetSchema), + defaultValues: { email: "" }, + }); + + const onSubmit = async (data: ResetFormData) => { + setFirebaseError(null); + try { + await sendPasswordResetEmail(auth, data.email); + setIsSuccess(true); + } catch (e) { + const err = e as FirebaseError; + setFirebaseError(err.code === "auth/user-not-found" + ? "Користувача з таким email не знайдено." + : "Сталася помилка. Спробуйте ще раз."); + } + }; + + return ( +
    + + + + + Повернутися до входу + + + + {!isSuccess ? ( + +
    + +
    + +

    + Забули пароль? +

    +

    + Не хвилюйтесь! Введіть email, пов'язаний з вашим акаунтом, і ми + надішлемо вам посилання. +

    + + +
    + +
    + + +
    + {errors.email && ( + + {errors.email.message} + + )} +
    + + {firebaseError && ( + + {firebaseError} + + )} + + + +
    + ) : ( + +
    + +
    + +
    +
    + +

    + Лист відправлено! +

    +

    + Перевірте пошту (і папку "Спам" про всяк випадок). Ми вже все надіслали! +

    + + + + +
    + )} +
    +
    +
    + ); +}; export default ForgotPassword; \ No newline at end of file From e246a8f698447409193c315ea842b0aea451b1ea Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Tue, 14 Apr 2026 19:09:58 +0100 Subject: [PATCH 163/369] fix: remane function --- backend/app/routes/teams.py | 4 ++-- backend/app/utils/routes/__init__.py | 2 +- backend/app/utils/routes/teams.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py index f9609e8..d333389 100644 --- a/backend/app/routes/teams.py +++ b/backend/app/routes/teams.py @@ -13,7 +13,7 @@ check_registration_open, get_team, validate_team_registration, - create_team, + create_team_record, ) router = APIRouter(prefix="/tournaments/{tournament_id}/teams", tags=["teams"]) @@ -47,7 +47,7 @@ async def create_team(tournament_id: int, team_data: TeamModel, session: Session await validate_team_registration(tournament, team_data, session) try: - new_team = await create_team(tournament_id, team_data, session) + new_team = await create_team_record(tournament_id, team_data, session) await session.commit() except IntegrityError as e: await session.rollback() diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py index 33235cd..01f7ff4 100644 --- a/backend/app/utils/routes/__init__.py +++ b/backend/app/utils/routes/__init__.py @@ -5,5 +5,5 @@ check_registration_open, get_team, validate_team_registration, - create_team, + create_team_record, ) diff --git a/backend/app/utils/routes/teams.py b/backend/app/utils/routes/teams.py index ea001ec..08663ac 100644 --- a/backend/app/utils/routes/teams.py +++ b/backend/app/utils/routes/teams.py @@ -76,7 +76,7 @@ async def validate_team_registration( ) -async def create_team( +async def create_team_record( tournament_id: int, team_data: TeamModel, session: AsyncSession ) -> Team: From 40d243d551c7f1a53441c33cb5c14b5103dee63d Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 14 Apr 2026 21:13:25 +0300 Subject: [PATCH 164/369] chore(.env.example): Add .env.example file --- .env.example | 13 +++++++++++++ package-lock.json | 6 ------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 .env.example delete mode 100644 package-lock.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..421ca22 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +SECRET_KEY="1234567890" +SQLALCHEMY_DATABASE_URI="postgresql://user:password@localhost/dbname" +VITE_BACKEND_URL=http://127.0.1:8000 +VITE_SOCKETIO_SERVER_URL=http://127.0.0.1:8000 + +# Налаштування Firebase +VITE_FIREBASE_API_KEY="" +VITE_FIREBASE_AUTH_DOMAIN="" +VITE_FIREBASE_PROJECT_ID="" +VITE_FIREBASE_STORAGE_BUCKET="" +VITE_FIREBASE_MESSAGING_SENDER_ID="" +VITE_FIREBASE_APP_ID="" +VITE_FIREBASE_MEASUREMENT_ID="" diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 859d3e6..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "StarForLife", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 4f971903f31a0da9480967f374cc0e5453185b88 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 14 Apr 2026 21:20:00 +0300 Subject: [PATCH 165/369] test: test for ForgotPassword page --- .../src/pages/Auth/ForgotPassword.test.tsx | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 frontend/src/pages/Auth/ForgotPassword.test.tsx diff --git a/frontend/src/pages/Auth/ForgotPassword.test.tsx b/frontend/src/pages/Auth/ForgotPassword.test.tsx new file mode 100644 index 0000000..1fabc3c --- /dev/null +++ b/frontend/src/pages/Auth/ForgotPassword.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { ForgotPassword } from './ForgotPassword'; + +import { sendPasswordResetEmail } from 'firebase/auth'; +import { auth } from '../../firebase'; + +// Mocks +vi.mock('firebase/auth', () => ({ + sendPasswordResetEmail: vi.fn(), +})); + +vi.mock('../../firebase', () => ({ + auth: {}, +})); + +vi.mock('../../components/ui', () => ({ + Button: ({ children, isLoading, ...props }: any) => ( + + ), +})); + +describe('ForgotPassword Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderForgotPassword = () => { + const user = userEvent.setup(); + render( + + + + ); + return { user }; + }; + + // Render & Basic UI Elements + describe('1. Initial Render & UI Elements', () => { + it('renders the main heading and description', () => { + renderForgotPassword(); + expect(screen.getByText('Забули пароль?')).toBeInTheDocument(); + expect( + screen.getByText(/Введіть email, пов'язаний з вашим акаунтом, і ми надішлемо вам посилання/i) + ).toBeInTheDocument(); + }); + + it('renders the email input field with correct placeholder', () => { + renderForgotPassword(); + const emailInput = screen.getByPlaceholderText('name@example.com'); + expect(emailInput).toBeInTheDocument(); + expect(emailInput).toHaveAttribute('type', 'email'); + }); + + it('renders the submit button', () => { + renderForgotPassword(); + expect(screen.getByRole('button', { name: 'Надіслати посилання' })).toBeInTheDocument(); + }); + + it('renders the back to login link', () => { + renderForgotPassword(); + const backLink = screen.getByText('Повернутися до входу'); + expect(backLink).toBeInTheDocument(); + expect(backLink.closest('a')).toHaveAttribute('href', '/auth'); + }); + }); + + // Zod Validation + describe('2. Form Validation (Zod)', () => { + it('shows an error when submitting empty email', async () => { + renderForgotPassword(); + const form = screen.getByRole('button', { name: 'Надіслати посилання' }).closest('form'); + fireEvent.submit(form!); + + expect(await screen.findByText('Некоректний формат email')).toBeInTheDocument(); + expect(sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it('shows an error for invalid email format', async () => { + const { user } = renderForgotPassword(); + + await user.type(screen.getByPlaceholderText('name@example.com'), 'invalid-email-format'); + + const form = screen.getByRole('button', { name: 'Надіслати посилання' }).closest('form'); + fireEvent.submit(form!); + + expect(await screen.findByText('Некоректний формат email')).toBeInTheDocument(); + expect(sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it('applies error styling to the input field on validation failure', async () => { + renderForgotPassword(); + const emailInput = screen.getByPlaceholderText('name@example.com'); + + const form = screen.getByRole('button', { name: 'Надіслати посилання' }).closest('form'); + fireEvent.submit(form!); + + await waitFor(() => { + expect(emailInput).toHaveClass('border-red-500'); + }); + }); + }); + + // Happy Paths + describe('3. Successful Flows', () => { + it('calls sendPasswordResetEmail and renders success UI', async () => { + vi.mocked(sendPasswordResetEmail).mockResolvedValue(undefined); + const { user } = renderForgotPassword(); + + await user.type(screen.getByPlaceholderText('name@example.com'), 'user@example.com'); + await user.click(screen.getByRole('button', { name: 'Надіслати посилання' })); + + await waitFor(() => { + expect(sendPasswordResetEmail).toHaveBeenCalledWith(auth, 'user@example.com'); + }); + + expect(await screen.findByText('Лист відправлено!')).toBeInTheDocument(); + expect( + screen.getByText(/Перевірте пошту \(і папку "Спам" про всяк випадок\)/i) + ).toBeInTheDocument(); + + expect(screen.queryByPlaceholderText('name@example.com')).not.toBeInTheDocument(); + }); + }); + + describe('4. Loading States', () => { + it('disables submit button and shows loading state during submission', async () => { + let resolvePromise: (value: any) => void; + const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); + vi.mocked(sendPasswordResetEmail).mockReturnValue(pendingPromise as any); + + const { user } = renderForgotPassword(); + + await user.type(screen.getByPlaceholderText('name@example.com'), 'test@example.com'); + const submitBtn = screen.getByRole('button', { name: 'Надіслати посилання' }); + await user.click(submitBtn); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(submitBtn).toBeDisabled(); + resolvePromise!(undefined); + }); + }); + describe('5. Firebase Error Handling', () => { + it('displays error message for "auth/user-not-found"', async () => { + vi.mocked(sendPasswordResetEmail).mockRejectedValue({ code: 'auth/user-not-found' }); + const { user } = renderForgotPassword(); + + await user.type(screen.getByPlaceholderText('name@example.com'), 'ghost@example.com'); + await user.click(screen.getByRole('button', { name: 'Надіслати посилання' })); + + expect(await screen.findByText('Користувача з таким email не знайдено.')).toBeInTheDocument(); + }); + + it('displays generic error message for other Firebase errors', async () => { + vi.mocked(sendPasswordResetEmail).mockRejectedValue({ code: 'auth/network-request-failed' }); + const { user } = renderForgotPassword(); + + await user.type(screen.getByPlaceholderText('name@example.com'), 'network@example.com'); + await user.click(screen.getByRole('button', { name: 'Надіслати посилання' })); + + expect(await screen.findByText('Сталася помилка. Спробуйте ще раз.')).toBeInTheDocument(); + }); + + it('clears previous firebase error when submitting again', async () => { + vi.mocked(sendPasswordResetEmail).mockRejectedValueOnce({ code: 'auth/user-not-found' }); + const { user } = renderForgotPassword(); + + await user.type(screen.getByPlaceholderText('name@example.com'), 'fail@example.com'); + await user.click(screen.getByRole('button', { name: 'Надіслати посилання' })); + + expect(await screen.findByText('Користувача з таким email не знайдено.')).toBeInTheDocument(); + let resolvePromise: (value: any) => void; + const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); + vi.mocked(sendPasswordResetEmail).mockReturnValueOnce(pendingPromise as any); + + await user.click(screen.getByRole('button', { name: 'Надіслати посилання' })); + await waitFor(() => { + expect(screen.queryByText('Користувача з таким email не знайдено.')).not.toBeInTheDocument(); + }); + + resolvePromise!(undefined); + }); + }); +}); \ No newline at end of file From 5a4384bf5d33546662257b6d5a060e70b9644821 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 14 Apr 2026 21:29:47 +0300 Subject: [PATCH 166/369] feat(sockets): Add error message if sockets doesnt work --- frontend/src/App.tsx | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 733fc4e..4d172ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,40 +13,51 @@ import { RulesPage } from "./pages/Rules/Rules"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; -import { Toaster } from 'react-hot-toast'; +import { Toaster, toast } from 'react-hot-toast'; import { AuthPage } from "./pages/Auth/AuthPage"; import SignOut from "./pages/Auth/SignOut"; import { useNotificationsSocket } from "./hooks/useNotificationsSocket"; import { io, Socket } from 'socket.io-client'; import { auth } from "./firebase"; +import { onAuthStateChanged } from "firebase/auth"; import { useEffect, useState } from "react"; export const App = () => { const [socket, setSocket] = useState(null); + useEffect(() => { - const initSocket = async () => { - const token = await auth.currentUser?.getIdToken(); - if (token) { - const s = io(import.meta.env.VITE_SOCKETIO_SERVER_URL, { + let currentSocket: Socket | null = null; + + const unsubscribeAuth = onAuthStateChanged(auth, async (user) => { + if (user) { + const token = await user.getIdToken(); + currentSocket = io(import.meta.env.VITE_SOCKETIO_SERVER_URL, { auth: { token } }); + currentSocket.on("connect_error", () => { + toast.error("Проблеми з сервером :(. Сповіщення тимчасово не працюють", { id: "socket-error", duration: 5000000000000000 }); + }); + currentSocket.on("connect", () => { + toast.dismiss("socket-error"); + }); - setSocket(s); - - return () => { - s.disconnect(); - }; + setSocket(currentSocket); + } else { + if (currentSocket) { + currentSocket.disconnect(); + setSocket(null); + } + } + }); + return () => { + unsubscribeAuth(); + if (currentSocket) { + currentSocket.disconnect(); } }; - - initSocket(); }, []); useNotificationsSocket(socket); - if (!socket) { - return
    Connecting to notifications...
    ; - } - return ( <> From ad904a31a8d984e84afc98e74c11d2b653a421d1 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Tue, 14 Apr 2026 22:36:54 +0300 Subject: [PATCH 167/369] chore: Some changes --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d172ed..5e2508d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,7 +35,7 @@ export const App = () => { auth: { token } }); currentSocket.on("connect_error", () => { - toast.error("Проблеми з сервером :(. Сповіщення тимчасово не працюють", { id: "socket-error", duration: 5000000000000000 }); + toast.error("Проблеми з сервером :(. Сповіщення тимчасово не працюють", { id: "socket-error" }); }); currentSocket.on("connect", () => { toast.dismiss("socket-error"); From 24865769aa91e5dcc74041603d0658a4fdbf6d08 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 15 Apr 2026 18:20:15 +0300 Subject: [PATCH 168/369] feat(notifications): show notifications form backend and sockets --- frontend/src/components/NotificationsDropdown.tsx | 15 +++++---------- frontend/src/hooks/useNotificationsSocket.ts | 10 +++++----- frontend/src/slices/notifications.ts | 12 +++++++++++- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/NotificationsDropdown.tsx b/frontend/src/components/NotificationsDropdown.tsx index c4fdb97..6ee4d3f 100644 --- a/frontend/src/components/NotificationsDropdown.tsx +++ b/frontend/src/components/NotificationsDropdown.tsx @@ -6,8 +6,8 @@ export const NotificationsDropdown = () => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const notifications = useSelector((s: RootState) => s.notifications?.items || []); - + const notifications = useSelector((s: RootState) => s.notifications.items); + const toggleMenu = () => setIsOpen(!isOpen); useEffect(() => { @@ -50,14 +50,9 @@ export const NotificationsDropdown = () => {
    {notifications.length > 0 ? ( - notifications.map((notification) => ( -
    -

    - {notification.body} -

    + notifications.map((notification, index) => ( +
    +

    {notification.body}

    )) ) : ( diff --git a/frontend/src/hooks/useNotificationsSocket.ts b/frontend/src/hooks/useNotificationsSocket.ts index 5f249d5..28482d8 100644 --- a/frontend/src/hooks/useNotificationsSocket.ts +++ b/frontend/src/hooks/useNotificationsSocket.ts @@ -3,23 +3,23 @@ import { useDispatch } from "react-redux"; import { addNotification } from "../slices/notifications"; import type { Socket } from "socket.io-client"; - export const useNotificationsSocket = (socket: Socket | null) => { const dispatch = useDispatch(); useEffect(() => { - const handleNewNotification = (data: { body: string }) => { + if (!socket) return; + + const handleNewNotification = (data: { body: string; id?: string }) => { dispatch( addNotification({ - id: crypto.randomUUID(), + id: data.id || crypto.randomUUID(), body: data.body, + isRead: false }) ); }; - if (!socket) return; socket.on("notification", handleNewNotification); - return () => { socket.off("notification", handleNewNotification); }; diff --git a/frontend/src/slices/notifications.ts b/frontend/src/slices/notifications.ts index 73387a6..35b7a29 100644 --- a/frontend/src/slices/notifications.ts +++ b/frontend/src/slices/notifications.ts @@ -1,4 +1,5 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import { setUser } from "./user"; export interface AppNotification { id: string; @@ -19,12 +20,21 @@ const notificationsSlice = createSlice({ initialState, reducers: { addNotification: (state, action: PayloadAction) => { - state.items.unshift(action.payload); + if (!state.items.find(n => n.id === action.payload.id)) { + state.items.unshift(action.payload); + } }, clearNotifications: (state) => { state.items = []; }, }, + extraReducers: (builder) => { + builder.addCase(setUser, (state, action) => { + if (action.payload && action.payload.notifications) { + state.items = action.payload.notifications; + } + }); + }, }); export const { addNotification, clearNotifications } = notificationsSlice.actions; From 7d7070fdaeeb30a3f730ab2d0cc173407690d616 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Wed, 15 Apr 2026 20:27:03 +0300 Subject: [PATCH 169/369] feat(Profile): Add roles for profile page --- frontend/src/pages/Profile/Profile.tsx | 201 +++++++++++-------------- 1 file changed, 84 insertions(+), 117 deletions(-) diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx index 7a3428f..4f8efa5 100644 --- a/frontend/src/pages/Profile/Profile.tsx +++ b/frontend/src/pages/Profile/Profile.tsx @@ -1,4 +1,4 @@ -import "./Profile.css"; +import "./Profile.css" import { useSelector } from "react-redux"; import { auth } from "../../firebase"; import { store, type RootState } from "../../store"; @@ -8,9 +8,11 @@ import { setUser } from "@/slices/user"; import { useState } from "react"; import { EditProfileModal } from "./EditProfileModal"; + const Profile = () => { const user = useSelector((s: RootState) => s.user.user); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const deleteUserMutation = useMutation({ mutationKey: ["delete user"], mutationFn: async () => { @@ -21,134 +23,73 @@ const Profile = () => { await auth.updateCurrentUser(null); store.dispatch(setUser(null)); }, - onError: (e) => { - console.log("An error occured while trying to delete account", e.message); + onError: (e: any) => { + console.log("An error occured", e.message); }, }); - const handleDeleteUser = async () => { - if (!auth.currentUser) return; - deleteUserMutation.mutate(); - }; - if (!user) return
    Loading...
    ; + + if (!user) return
    Loading...
    ; return ( -
    -
    -
    -
    -
    - - - - +
    +
    + +
    +
    + +
    +
    + + + + +
    +
    +

    {user?.displayName || user?.full_name}

    + + Роль: {user?.roles?.length > 0 + ? user.roles.map((r: any) => r.display_name || r.name).join(", ") + : "Немає ролей"} + +
    -
    -

    {user?.displayName}

    - Роль: Користувач + +
    + +
    - - -
    -
    +
    -
    -

    СПОСОБИ ЗВ'ЯЗКУ

    -
    -
    - Email: - {user.email ?? 'Відсутній'} -
    -
    - Telegram: - {user.telegram ?? 'Відсутній'} -
    -
    - GitHub: - {user.github ?? 'Відсутній'} -
    -
    - Discord: - {user.discord ?? 'Відсутній'} +
    +

    СПОСОБИ ЗВ'ЯЗКУ

    +
    + + + +
    -
    -
    -
    -

    - Турніри -

    -
    - {[ - "Напишіть Ядро Лінукс", - "Напишіть свою мову програмування на рівні C++", - "Напишіть гру на JavaScript", - "Напишіть чат-бота на Python", - "Напишіть свою операційну систему", - ].map((item, index) => ( -
    - {item} - - - -
    - ))} -
    -
    - -
    -

    - Команди -

    -
    - {["Шалені програмісти", "Кодери мрії", "Лінус Торвальдс"].map( - (item, index) => ( -
    -
    - - - - - - - - - -
    - {item} -
    - ), - )} -
    +
    + + +
    { ); }; -export { Profile }; +const ContactChip = ({ label, value, colorClass = "bg-[#f3f4f6] border-[#e5e7eb] text-[#111827]" }) => ( +
    + {label}: + {value ?? 'Відсутній'} +
    +); + +const ListCard = ({ title, dotColor, items, isTeams = false }) => ( +
    +

    + {title} +

    +
    + {items.map((item, i) => ( +
    +
    + {isTeams &&
    🤖
    } + {item} +
    + {!isTeams && } +
    + ))} +
    +
    +); + +export { Profile }; \ No newline at end of file From c113b5e296b887c08a32cb6431a8f9e9976b5c86 Mon Sep 17 00:00:00 2001 From: Yar00myr Date: Thu, 16 Apr 2026 22:24:18 +0100 Subject: [PATCH 170/369] fix: update schemas aftet merge --- backend/app/schemas/__init__.py | 4 +-- backend/app/schemas/tournament.py | 51 ++++++++++++++++++------------- backend/app/schemas/user.py | 34 +++++++++++++++++++-- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index c7a41cf..a5db010 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,7 +1,7 @@ from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionModel from .task_options import TaskRequirementOptionCreate, TaskRequirementOptionPublic -from .task import TaskBase, TaskCreate,TaskUpdate, TaskPublic +from .task import TaskBase, TaskCreate, TaskUpdate, TaskPublic from .team import TeamModel, TeamUpdate, TeamMemberModel, TeamMemberUpdate from .tournament import ( TournamentBase, @@ -13,4 +13,4 @@ from .user import UserModel, UserPublic, UserUpdate, UserCreate, CurrentUser from .role_request import RoleRequestPublic, RoleRequestCreate from .role import RoleCreate, RolePublic, RoleUpdate -from .notification import NotificationPublic, NotificationCreate \ No newline at end of file +from .notification import NotificationPublic, NotificationCreate diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 4fa7caf..469ca36 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -1,38 +1,45 @@ -from typing import Annotated from datetime import datetime from typing_extensions import Self from pydantic import BaseModel, Field, field_validator, model_validator +from typing import Annotated +from datetime import datetime +from pydantic import AfterValidator, BaseModel, ConfigDict, Field + + +StrippedStr = Annotated[str, AfterValidator(lambda v: v.strip())] -class TournamentModels(BaseModel): - title: str = Field(..., min_length=3) + +class TournamentBase(BaseModel): + title: StrippedStr = Field(..., min_length=3) description: str start_date: datetime reg_start: datetime reg_end: datetime - max_team: int = Field(..., gt=0) + max_team: int = Field(..., gt=1) - @field_validator("title") - @classmethod - def check_title(cls, value: str): - return value.strip() - @field_validator("reg_start") - @classmethod - def check__date(cls, value: datetime): - if value <= datetime.now(): - raise ValueError("Registration cannot start in the past") - return value +class TournamentCreate(TournamentBase): + pass - @model_validator(mode="after") - def check_dates(self) -> Self: - if self.reg_end <= self.reg_start: - raise ValueError("reg_end must be later than reg_start") - if self.start_date <= self.reg_end: - raise ValueError("Tournament must start after registration ends") - return self +class TournamentUpdate(BaseModel): + title: StrippedStr | None = Field(None, min_length=3) + description: str | None = None + start_date: datetime | None = None + reg_start: datetime | None = None + reg_end: datetime | None = None + max_team: int | None = Field(None, gt=1) class TournamentStatusOptionModel(BaseModel): - name: str = Field(..., min_length=3) + model_config = ConfigDict(from_attributes=True) + + id: int + name: StrippedStr = Field(..., min_length=3) + + +class TournamentPublic(TournamentBase): + model_config = ConfigDict(from_attributes=True) + + id: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 63f5875..4322554 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,9 +1,10 @@ from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from .role import RolePublic + class UserBase(BaseModel): model_config = ConfigDict(from_attributes=True) - + full_name: str = Field(..., description="Username") @field_validator("full_name") @@ -13,15 +14,44 @@ def check_name(cls, value: str): raise ValueError("The name cannot be empty") return value + +class UserCreate(UserBase): + firebase_uid: str = Field(..., description="Firebase user id") + email: EmailStr = Field(..., description="Email") + + +class UserUpdate(UserBase): + full_name: str | None = None + email: str | None = None + telegram: str | None = None + github: str | None = None + discord: str | None = None + + +class UserPublic(UserBase): + id: int + email: EmailStr + firebase_uid: str + roles: list[RolePublic] + telegram: str | None + github: str | None + discord: str | None + + +class UserModel(UserBase): + email: EmailStr = Field(..., description="User email") + @field_validator("email") @classmethod def check_email(cls, value: str): return value.lower().strip() + from .role_request import RoleRequestPublic from .notification import NotificationPublic + + # Return notifications of current user only # TODO: add created_tournaments after the TournamentPublic model will be defined class CurrentUser(UserPublic): notifications: list[NotificationPublic] - role_requests: list[RoleRequestPublic] \ No newline at end of file From 7142e26d76e7f7a1fa10503681239286df40aa55 Mon Sep 17 00:00:00 2001 From: izachoc Date: Fri, 17 Apr 2026 00:27:59 +0300 Subject: [PATCH 171/369] feat: setup i18n and polish header --- frontend/package-lock.json | 150 ++++++++++++- frontend/package.json | 4 + frontend/public/locales/en/common.json | 11 + frontend/public/locales/uk/common.json | 11 + frontend/src/App.tsx | 40 ++-- frontend/src/components/Header.tsx | 199 +++++++++++++----- frontend/src/components/ScrollToTop.tsx | 12 ++ .../src/components/ui/LanguageSwitcher.tsx | 47 +++++ frontend/src/i18n/config.ts | 23 ++ frontend/src/main.tsx | 16 +- 10 files changed, 439 insertions(+), 74 deletions(-) create mode 100644 frontend/public/locales/en/common.json create mode 100644 frontend/public/locales/uk/common.json create mode 100644 frontend/src/components/ScrollToTop.tsx create mode 100644 frontend/src/components/ui/LanguageSwitcher.tsx create mode 100644 frontend/src/i18n/config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26600a2..5ab2b2c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,12 +18,16 @@ "axios": "1.13.6", "firebase": "^12.10.0", "framer-motion": "^12.38.0", + "i18next": "^26.0.5", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-http-backend": "^3.0.4", "lottie-react": "^2.4.1", "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.3", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", @@ -358,7 +362,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3872,6 +3875,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4839,12 +4851,70 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", "license": "MIT" }, + "node_modules/i18next": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz", + "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.4.tgz", + "integrity": "sha512-udwrBIE6cNpqn1gRAqRULq3+7MzIIuaiKRWrz++dVz5SqWW2VwXmPJtAgkI0JtMLFaADC9qNmnZAxWAhsxXx2g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.1.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5584,6 +5654,48 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5891,6 +6003,33 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.3.tgz", + "integrity": "sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6729,6 +6868,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 490e05e..02b2987 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,12 +23,16 @@ "axios": "1.13.6", "firebase": "^12.10.0", "framer-motion": "^12.38.0", + "i18next": "^26.0.5", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-http-backend": "^3.0.4", "lottie-react": "^2.4.1", "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.3", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json new file mode 100644 index 0000000..5ff43bc --- /dev/null +++ b/frontend/public/locales/en/common.json @@ -0,0 +1,11 @@ +{ + "nav": { + "tournaments": "Tournaments", + "about": "About Us", + "support": "Support Us", + "contact": "Contact" + }, + "auth": { + "login": "Sign In" + } +} diff --git a/frontend/public/locales/uk/common.json b/frontend/public/locales/uk/common.json new file mode 100644 index 0000000..9f1e5d1 --- /dev/null +++ b/frontend/public/locales/uk/common.json @@ -0,0 +1,11 @@ +{ + "nav": { + "tournaments": "Турніри", + "about": "Про нас", + "support": "Підтримати", + "contact": "Контакти" + }, + "auth": { + "login": "Увійти" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e2508d..ee5fe60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { MainLayout } from "./components/MainLayout"; import { Home } from "./pages/Home/Home"; import { Profile } from "./pages/Profile/Profile"; @@ -13,15 +13,24 @@ import { RulesPage } from "./pages/Rules/Rules"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; -import { Toaster, toast } from 'react-hot-toast'; +import { Toaster, toast } from "react-hot-toast"; import { AuthPage } from "./pages/Auth/AuthPage"; import SignOut from "./pages/Auth/SignOut"; import { useNotificationsSocket } from "./hooks/useNotificationsSocket"; -import { io, Socket } from 'socket.io-client'; +import { io, Socket } from "socket.io-client"; import { auth } from "./firebase"; -import { onAuthStateChanged } from "firebase/auth"; +import { onAuthStateChanged } from "firebase/auth"; import { useEffect, useState } from "react"; +// Утиліта для скролу нагору при кожній зміні URL +const ScrollToTop = () => { + const { pathname } = useLocation(); + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + return null; +}; + export const App = () => { const [socket, setSocket] = useState(null); @@ -32,10 +41,13 @@ export const App = () => { if (user) { const token = await user.getIdToken(); currentSocket = io(import.meta.env.VITE_SOCKETIO_SERVER_URL, { - auth: { token } + auth: { token }, }); currentSocket.on("connect_error", () => { - toast.error("Проблеми з сервером :(. Сповіщення тимчасово не працюють", { id: "socket-error" }); + toast.error( + "Проблеми з сервером :(. Сповіщення тимчасово не працюють", + { id: "socket-error" }, + ); }); currentSocket.on("connect", () => { toast.dismiss("socket-error"); @@ -62,6 +74,7 @@ export const App = () => { <> + {/* <--- Додали сюди! */} {/* Сторінки з Хедером та Футером */} }> @@ -76,11 +89,14 @@ export const App = () => { /> } /> } /> - - - - } /> + + + + } + /> } /> } /> } /> @@ -97,4 +113,4 @@ export const App = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 93c0876..468ca53 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,116 +1,199 @@ -import { useState } from "react"; -import { Link, NavLink } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { Link, NavLink, useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; import { type RootState } from "../store"; +import { useTranslation } from "react-i18next"; +import { motion, AnimatePresence } from "framer-motion"; import { ProfileDropdown } from "./ProfileDropdown"; import { NotificationsDropdown } from "./NotificationsDropdown"; +import { LanguageSwitcher } from "./ui/LanguageSwitcher"; export const Header = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const { t } = useTranslation("common"); + const user = useSelector((s: RootState) => s.user.user); + const location = useLocation(); + + useEffect(() => { + setIsMobileMenuOpen(false); + }, [location.pathname]); + + useEffect(() => { + const handleScroll = () => setIsScrolled(window.scrollY > 20); + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); const navItems = [ - { path: "/tournaments", label: "Турніри" }, - { path: "/about-us", label: "Про нас" }, - { path: "/support", label: "Чим ви можете допомогти" }, - { path: "/contact", label: "Контакти" }, + { path: "/tournaments", label: t("nav.tournaments", "Турніри") }, + { path: "/about-us", label: t("nav.about", "Про нас") }, + { path: "/support", label: t("nav.support", "Підтримати") }, + { path: "/contact", label: t("nav.contact", "Контакти") }, ]; - const user = useSelector((s: RootState) => s.user.user); - return ( -
    -
    +
    +
    - UGalaxy x Star for Life + UGalaxy{" "} + + x + {" "} + Star for Life -
    - {isMobileMenuOpen && ( -
    - {navItems.map((item) => ( - setIsMobileMenuOpen(false)} - className={({ isActive }) => - `block font-semibold text-[18px] py-2 transition-colors duration-300 ${isActive ? "text-accent" : "text-white hover:text-accent" - }` - } - > - {item.label} - - ))} + + {isMobileMenuOpen && ( + +
    + {navItems.map((item) => ( + + `block font-semibold text-[18px] transition-all duration-300 ${ + isActive + ? "text-[#fbbf24] translate-x-2" + : "text-white/90 hover:text-white hover:translate-x-2" + }` + } + > + {item.label} + + ))} - {user?.uid ? ( -
    - +
    + + + {user?.uid ? ( + + ) : ( + + + {t("auth.login", "Увійти")} + + + )} +
    - ) : ( - setIsMobileMenuOpen(false)} - className="btn btn-outline py-2 px-5 mt-2 text-center sm:hidden" - > - Увійти - - )} -
    - )} +
    + )} +
    ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ScrollToTop.tsx b/frontend/src/components/ScrollToTop.tsx new file mode 100644 index 0000000..6a001cf --- /dev/null +++ b/frontend/src/components/ScrollToTop.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +export const ScrollToTop = () => { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +}; diff --git a/frontend/src/components/ui/LanguageSwitcher.tsx b/frontend/src/components/ui/LanguageSwitcher.tsx new file mode 100644 index 0000000..4f47f52 --- /dev/null +++ b/frontend/src/components/ui/LanguageSwitcher.tsx @@ -0,0 +1,47 @@ +// LanguageSwitcher.tsx +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; + +export const LanguageSwitcher = () => { + const { i18n } = useTranslation(); + + const languages = [ + { code: "uk", label: "UK", flag: "🇺🇦", title: "Українська" }, + { code: "en", label: "EN", flag: "🇬🇧", title: "English" }, + ]; + + return ( +
    + {languages.map((lang) => { + const isActive = i18n.resolvedLanguage === lang.code; + return ( + + ); + })} +
    + ); +}; diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 0000000..c499c96 --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -0,0 +1,23 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import HttpBackend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; + +i18n + .use(HttpBackend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: "uk", + supportedLngs: ["uk", "en"], + debug: import.meta.env.DEV, + + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: "/locales/{{lng}}/{{ns}}.json", + }, + }); + +export default i18n; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 288fee0..9ade1ce 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,7 @@ -import { StrictMode } from "react"; +import { StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import { ui } from './firebase'; +import { ui } from "./firebase"; import { App } from "./App"; import { FirebaseUIProvider } from "@firebase-oss/ui-react"; import { Provider } from "react-redux"; @@ -9,12 +9,22 @@ import { store } from "./store"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./api/queryClient"; +import "./i18n/config"; + createRoot(document.getElementById("root")!).render( - + + Завантаження мови... +
    + } + > + + From 7f42fdfd4751af7846067c843f18b14254ad5dd8 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Fri, 17 Apr 2026 09:49:34 +0300 Subject: [PATCH 172/369] chore(getRole): Remove buttons for getting andmin and jury role --- .../src/pages/GetRole/RoleRequestPage.tsx | 77 ++++--------------- 1 file changed, 14 insertions(+), 63 deletions(-) diff --git a/frontend/src/pages/GetRole/RoleRequestPage.tsx b/frontend/src/pages/GetRole/RoleRequestPage.tsx index 04defb8..33aa537 100644 --- a/frontend/src/pages/GetRole/RoleRequestPage.tsx +++ b/frontend/src/pages/GetRole/RoleRequestPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import Lottie from 'lottie-react'; import star from './star.json'; import { useSelector } from "react-redux"; @@ -6,41 +6,17 @@ import type { RootState } from "@/store"; import { requestRole } from '@/api/requests/requestRole'; import { useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; -import apiClient from '@/api/client'; import { auth } from '@/firebase'; -interface Role { - name: string; - display_name: string; - description: string; -} - const RoleRequestPage = () => { const user = useSelector((state: RootState) => state.user.user); const navigate = useNavigate(); - const [roles, setRoles] = useState([]); - const [selectedRole, setSelectedRole] = useState(null); - const [isLoadingRoles, setIsLoadingRoles] = useState(true); - - useEffect(() => { - const fetchRoles = async () => { - try { - const response = await apiClient.get('/roles/'); - const data: Role[] = response.data; - const filteredRoles = data.filter(role => role.name !== 'user'); - setRoles(filteredRoles); - if (filteredRoles.length > 0) { - setSelectedRole(filteredRoles[0]); - } - } catch (error) { - toast.error("Не вдалося завантажити список ролей"); - } finally { - setIsLoadingRoles(false); - } - }; - fetchRoles(); - }, []); + const ORGANIZER_ROLE = { + name: 'organizer', + display_name: 'Організатора', + description: 'Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну.' + }; const createRequests = async (e: React.FormEvent) => { e.preventDefault(); @@ -50,11 +26,6 @@ const RoleRequestPage = () => { return; } - if (!selectedRole) { - toast.error("Будь ласка, оберіть роль, на яку подаєте заявку!"); - return; - } - const form = e.currentTarget; const formData = new FormData(form); @@ -69,7 +40,7 @@ const RoleRequestPage = () => { try { if (!auth.currentUser) return; - await requestRole(selectedRole.name, auth.currentUser, user.id, info); + await requestRole(ORGANIZER_ROLE.name, auth.currentUser, user.id, info); toast.success("Заявку відправлено! Очікуйте на відповідь"); form.reset(); navigate("/"); @@ -102,10 +73,10 @@ const RoleRequestPage = () => {

    STAR FOR LIFE

    - Заявка на роль {selectedRole ? selectedRole.display_name.toLowerCase() : '...'} + Заявка на роль {ORGANIZER_ROLE.display_name}

    - {selectedRole ? selectedRole.description : 'Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну.'} + {ORGANIZER_ROLE.description}

    Важливо: відповідайте чесно та детально — це підвищує ваші шанси.

    @@ -114,30 +85,11 @@ const RoleRequestPage = () => {
    -
    -

    Оберіть бажану роль:

    - {isLoadingRoles ? ( -

    Завантаження ролей...

    - ) : ( -
    - {roles.map((role) => { - const isActive = selectedRole?.name === role.name; - return ( - - ); - })} -
    - )} + +
    +

    + Наразі ви можете отримати лише роль Організатора. Запити на інші ролі тимчасово вимкнені. +

    * — обов'язкове поле

    @@ -175,7 +127,6 @@ const RoleRequestPage = () => { From d158c611e3015d063f2544e626701f2917456f33 Mon Sep 17 00:00:00 2001 From: AnnPoshtak Date: Fri, 17 Apr 2026 10:09:34 +0300 Subject: [PATCH 173/369] test: add somw new test for Auth pages. Rewrite test on profile and getRole pagw --- frontend/src/pages/Auth/Auth.test.tsx | 10 +- .../src/pages/Auth/ForgotPassword.test.tsx | 9 + .../Auth/__snapshots__/Auth.test.tsx.snap | 288 +++++++++ .../ForgotPassword.test.tsx.snap | 135 +++++ .../src/pages/GetRole/RoleRequst.test.tsx | 108 ++-- .../__snapshots__/RoleRequst.test.tsx.snap | 194 ++++++ frontend/src/pages/Profile/Profile.test.tsx | 57 +- .../__snapshots__/Profile.test.tsx.snap | 557 +++++++----------- .../TournamentPage.test.tsx.snap | 70 --- 9 files changed, 930 insertions(+), 498 deletions(-) create mode 100644 frontend/src/pages/Auth/__snapshots__/Auth.test.tsx.snap create mode 100644 frontend/src/pages/Auth/__snapshots__/ForgotPassword.test.tsx.snap create mode 100644 frontend/src/pages/GetRole/__snapshots__/RoleRequst.test.tsx.snap delete mode 100644 frontend/src/pages/TournamentPage/__snapshots__/TournamentPage.test.tsx.snap diff --git a/frontend/src/pages/Auth/Auth.test.tsx b/frontend/src/pages/Auth/Auth.test.tsx index 8242361..1c23727 100644 --- a/frontend/src/pages/Auth/Auth.test.tsx +++ b/frontend/src/pages/Auth/Auth.test.tsx @@ -13,7 +13,6 @@ import { signOut, } from 'firebase/auth'; import { syncUser } from '../../firebase'; - const mockNavigate = vi.fn(); vi.mock('react-router-dom', async () => { @@ -63,6 +62,15 @@ describe('AuthPage Component', () => { return { user }; }; + it('matches snapshot', () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); +}); + // Render & Basic UI Elements describe('1. Initial Render & UI Elements', () => { it('renders the registration form by default', () => { diff --git a/frontend/src/pages/Auth/ForgotPassword.test.tsx b/frontend/src/pages/Auth/ForgotPassword.test.tsx index 1fabc3c..23fdd5a 100644 --- a/frontend/src/pages/Auth/ForgotPassword.test.tsx +++ b/frontend/src/pages/Auth/ForgotPassword.test.tsx @@ -39,6 +39,15 @@ describe('ForgotPassword Component', () => { return { user }; }; + it('matches snapshot', () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); + }); + // Render & Basic UI Elements describe('1. Initial Render & UI Elements', () => { it('renders the main heading and description', () => { diff --git a/frontend/src/pages/Auth/__snapshots__/Auth.test.tsx.snap b/frontend/src/pages/Auth/__snapshots__/Auth.test.tsx.snap new file mode 100644 index 0000000..abeead9 --- /dev/null +++ b/frontend/src/pages/Auth/__snapshots__/Auth.test.tsx.snap @@ -0,0 +1,288 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AuthPage Component > matches snapshot 1`] = ` +
    + +
    + +
    +
    +
    +

    + Створити акаунт +

    +

    + Готовий до нових челенджів? +

    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + + Забули пароль? + +
    +
    + + +
    +
    + +
    +
    + + або + +
    +
    + +

    + Реєструючись + , ти погоджуєшся з +
    + + Умовами використання + + + та + + + Політикою конфіденційності + +

    + +
    +
    +
    +
    +`; diff --git a/frontend/src/pages/Auth/__snapshots__/ForgotPassword.test.tsx.snap b/frontend/src/pages/Auth/__snapshots__/ForgotPassword.test.tsx.snap new file mode 100644 index 0000000..623bfed --- /dev/null +++ b/frontend/src/pages/Auth/__snapshots__/ForgotPassword.test.tsx.snap @@ -0,0 +1,135 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ForgotPassword Component > matches snapshot 1`] = ` +
    +
    +
    + + + Повернутися до входу + +
    +
    + +
    +

    + Забули пароль? +

    +

    + Не хвилюйтесь! Введіть email, пов'язаний з вашим акаунтом, і ми надішлемо вам посилання. +

    +
    +
    + +
    + + +
    +
    + +
    +
    +
    +
    +
    +`; diff --git a/frontend/src/pages/GetRole/RoleRequst.test.tsx b/frontend/src/pages/GetRole/RoleRequst.test.tsx index 14764ea..e617f32 100644 --- a/frontend/src/pages/GetRole/RoleRequst.test.tsx +++ b/frontend/src/pages/GetRole/RoleRequst.test.tsx @@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; import { RoleRequestPage } from './RoleRequestPage'; import { requestRole } from '@/api/requests/requestRole'; -import apiClient from '@/api/client'; +import { auth } from '@/firebase'; vi.mock('react-redux', () => ({ useSelector: vi.fn(), @@ -26,12 +26,6 @@ vi.mock('@/api/requests/requestRole', () => ({ requestRole: vi.fn(), })); -vi.mock('@/api/client', () => ({ - default: { - get: vi.fn(), - }, -})); - vi.mock('@/firebase', () => ({ auth: { currentUser: { uid: 'mock-user-123' }, @@ -45,87 +39,83 @@ vi.mock('lottie-react', () => ({ describe('RoleRequestPage Component', () => { const mockNavigate = vi.fn(); - const mockRoles = [ - { name: 'admin', display_name: 'Administrator', description: 'Admin role' }, - { name: 'moderator', display_name: 'Moderator', description: 'Mod role' }, - { name: 'user', display_name: 'User', description: 'Regular user' }, - ]; - beforeEach(() => { vi.clearAllMocks(); vi.mocked(useNavigate).mockReturnValue(mockNavigate); - vi.mocked(apiClient.get).mockResolvedValue({ data: mockRoles }); + auth.currentUser = { uid: 'mock-user-123' } as any; }); - it('renders loading state initially', () => { + it('matches snapshot', () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); - render(); - - expect(screen.getByText('Завантаження ролей...')).toBeInTheDocument(); + const { container } = render(); + + expect(container).toMatchSnapshot(); }); - it('fetches and displays roles correctly, filtering out "user"', async () => { + it('renders correctly with hardcoded organizer role and warning message', () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); render(); - - await waitFor(() => { - expect(screen.getByText('Administrator')).toBeInTheDocument(); - expect(screen.getByText('Moderator')).toBeInTheDocument(); - }); - - expect(screen.queryByText('User')).not.toBeInTheDocument(); - expect(screen.getByText('Заявка на роль administrator')).toBeInTheDocument(); + + expect(screen.getByText('Заявка на роль Організатора')).toBeInTheDocument(); + expect(screen.getByText(/Наразі ви можете отримати лише роль Організатора/i)).toBeInTheDocument(); + expect(screen.getByTestId('lottie-mock')).toBeInTheDocument(); }); - it('shows error toast if role fetching fails', async () => { + it('allows user to type in form fields', () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(apiClient.get).mockRejectedValue(new Error('Network Error')); - render(); - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Не вдалося завантажити список ролей'); - }); + const nameInput = screen.getByLabelText(/ПІБ \*/i); + fireEvent.change(nameInput, { target: { value: 'Ivan Franko' } }); + + expect((nameInput as HTMLInputElement).value).toBe('Ivan Franko'); }); - it('shows error when user is missing on submit', async () => { + it('shows error toast when user is not in redux store on submit', () => { vi.mocked(useSelector).mockReturnValue(null); const { container } = render(); - await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); - fireEvent.submit(container.querySelector('form')!); expect(toast.error).toHaveBeenCalledWith('Користувач не знайдений або не авторизований!'); + expect(requestRole).not.toHaveBeenCalled(); }); - it('submits form successfully and navigates', async () => { + it('returns early and does not submit if auth.currentUser is null', async () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(requestRole).mockResolvedValue(200 as any); + auth.currentUser = null; const { container } = render(); + fireEvent.submit(container.querySelector('form')!); - await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); + await waitFor(() => { + expect(requestRole).not.toHaveBeenCalled(); + }); + }); - fireEvent.click(screen.getByText('Moderator')); + it('submits form successfully with all fields and navigates to home', async () => { + vi.mocked(useSelector).mockReturnValue({ id: '123' }); + vi.mocked(requestRole).mockResolvedValue(200 as any); - fireEvent.change(screen.getByLabelText(/ПІБ \*/i), { target: { value: 'Толька' } }); - fireEvent.change(screen.getByLabelText(/Email \/ Telegram \*/i), { target: { value: '@tolka_boss' } }); - fireEvent.change(screen.getByLabelText(/Ваш вік \*/i), { target: { value: '22' } }); - fireEvent.change(screen.getByLabelText(/Чи маєте релевантний досвід\? \*/i), { target: { value: 'Досвід бути Толькою' } }); - fireEvent.change(screen.getByLabelText(/Чому ви хочете отримати цю роль\? \*/i), { target: { value: 'Бо я Толька' } }); - fireEvent.change(screen.getByLabelText(/Що ви плануєте робити на цій ролі\? \*/i), { target: { value: 'Наводити порядки' } }); + const { container } = render(); + + fireEvent.change(screen.getByLabelText(/ПІБ \*/i), { target: { value: 'Tolka' } }); + fireEvent.change(screen.getByLabelText(/Email \/ Telegram \*/i), { target: { value: '@tolka' } }); + fireEvent.change(screen.getByLabelText(/Ваш вік \*/i), { target: { value: '25' } }); + fireEvent.change(screen.getByLabelText(/Чи маєте релевантний досвід\? \*/i), { target: { value: 'Yes' } }); + fireEvent.change(screen.getByLabelText(/Чому ви хочете отримати цю роль\? \*/i), { target: { value: 'Because' } }); + fireEvent.change(screen.getByLabelText(/Що ви плануєте робити на цій ролі\? \*/i), { target: { value: 'Work' } }); fireEvent.submit(container.querySelector('form')!); await waitFor(() => { - expect(requestRole).toHaveBeenCalledWith('moderator', { uid: 'mock-user-123' }, '123', [ - { option_name: 'ПІБ', value: 'Толька' }, - { option_name: 'Контакти', value: '@tolka_boss' }, - { option_name: 'Вік', value: '22' }, - { option_name: 'Досвід', value: 'Досвід бути Толькою' }, - { option_name: 'Причина', value: 'Бо я Толька' }, - { option_name: 'Плани', value: 'Наводити порядки' }, + expect(requestRole).toHaveBeenCalledWith('organizer', { uid: 'mock-user-123' }, '123', [ + { option_name: 'ПІБ', value: 'Tolka' }, + { option_name: 'Контакти', value: '@tolka' }, + { option_name: 'Вік', value: '25' }, + { option_name: 'Досвід', value: 'Yes' }, + { option_name: 'Причина', value: 'Because' }, + { option_name: 'Плани', value: 'Work' }, ]); }); @@ -133,7 +123,7 @@ describe('RoleRequestPage Component', () => { expect(mockNavigate).toHaveBeenCalledWith('/'); }); - it('shows duplicate error on 400 response', async () => { + it('shows duplicate error toast on 400 response with specific detail', async () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); vi.mocked(requestRole).mockRejectedValue({ response: { status: 400, data: { detail: 'Role requests already exists!' } } @@ -141,27 +131,25 @@ describe('RoleRequestPage Component', () => { const { container } = render(); - await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); - fireEvent.submit(container.querySelector('form')!); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('Ви вже подавали заявку на цю роль! Очікуйте на рішення.'); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); - it('shows fallback error on API failure', async () => { + it('shows fallback error toast on random API failure', async () => { vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(requestRole).mockRejectedValue(new Error('Network Error')); + vi.mocked(requestRole).mockRejectedValue(new Error('Internal Server Error')); const { container } = render(); - await waitFor(() => expect(screen.getByText('Administrator')).toBeInTheDocument()); - fireEvent.submit(container.querySelector('form')!); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('Щось пішло не так. Спробуйте пізніше.'); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); }); \ No newline at end of file diff --git a/frontend/src/pages/GetRole/__snapshots__/RoleRequst.test.tsx.snap b/frontend/src/pages/GetRole/__snapshots__/RoleRequst.test.tsx.snap new file mode 100644 index 0000000..01a2afc --- /dev/null +++ b/frontend/src/pages/GetRole/__snapshots__/RoleRequst.test.tsx.snap @@ -0,0 +1,194 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RoleRequestPage Component > matches snapshot 1`] = ` +
    +
    +
    +
    +
    +
    +
    +

    + UGalaxy +

    +

    + STAR FOR LIFE +

    +

    + Заявка на роль + Організатора +

    +

    + Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну. +
    +
    + + Важливо: + + відповідайте чесно та детально — це підвищує ваші шанси. +

    +
    +
    +
    +
    +
    +

    + Наразі ви можете отримати лише роль Організатора. Запити на інші ролі тимчасово вимкнені. +

    +
    +

    + * — обов'язкове поле +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    - if (!auth.currentUser) return; - - const formData = new FormData(e.currentTarget); - - const info: RoleRequestOption[] = [ - { option_name: "ПІБ", value: (formData.get("fullName") as string) || "" }, - { option_name: "Контакти", value: (formData.get("contact") as string) || "" }, - { option_name: "Вік", value: (formData.get("age") as string) || "" }, - { option_name: "Досвід", value: (formData.get("experience") as string) || "" }, - { option_name: "Причина", value: (formData.get("reason") as string) || "" }, - { option_name: "Плани", value: (formData.get("plans") as string) || "" }, - ]; - - mutation.mutate({ - role: ORGANIZER_ROLE.name, - currentUser: auth.currentUser, - userId: user.id, - info - }); - }; - - const inputClasses = "peer w-full px-[18px] pt-6 pb-2 border border-slate-200 rounded-xl bg-[#fafafa] text-sm text-gray-800 transition-all focus:outline-none focus:border-[#7b00ff] focus:bg-white focus:shadow-[0_0_0_3px_rgba(123,0,255,0.1)] placeholder-transparent"; - const labelClasses = "absolute left-[18px] top-1.5 text-[11px] font-medium text-[#7b00ff] pointer-events-none transition-all peer-placeholder-shown:top-[18px] peer-placeholder-shown:text-sm peer-placeholder-shown:text-gray-400 peer-placeholder-shown:font-normal peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-[#7b00ff] peer-focus:font-medium"; - - return ( -
    -
    -
    -
    - -
    -

    UGalaxy

    -

    STAR FOR LIFE

    - -

    - Заявка на роль Організатора -

    -

    - Заповніть форму нижче, щоб подати заявку. Ми розглядаємо кожну заявку вручну. -

    - Важливо: відповідайте чесно та детально — це підвищує ваші шанси. -

    -
    +
    + +
    -
    -
    - -
    -

    - Наразі ви можете отримати лише роль Організатора -

    -
    - -

    * — обов'язкове поле

    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - - - -
    +
    + +
    + + +
    - ) -} +
    +
    + ); +}; -export { RoleRequestPage } \ No newline at end of file +export { RoleRequestPage }; diff --git a/frontend/src/pages/GetRole/RoleRequst.test.tsx b/frontend/src/pages/GetRole/RoleRequst.test.tsx index c3adbfc..8595592 100644 --- a/frontend/src/pages/GetRole/RoleRequst.test.tsx +++ b/frontend/src/pages/GetRole/RoleRequst.test.tsx @@ -1,50 +1,50 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; -import { toast } from 'sonner'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { RoleRequestPage } from './RoleRequestPage'; -import { requestRole } from '@/api/requests/requestRole'; -import { auth } from '@/firebase'; - -vi.mock('react-redux', () => ({ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RoleRequestPage } from "./RoleRequestPage"; +import { requestRole } from "@/api/requests/requestRole"; +import { auth } from "@/firebase"; + +vi.mock("react-redux", () => ({ useSelector: vi.fn(), })); -vi.mock('react-router-dom', () => ({ +vi.mock("react-router-dom", () => ({ useNavigate: vi.fn(), })); -vi.mock('sonner', () => ({ +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), }, })); -vi.mock('@/api/requests/requestRole', () => ({ +vi.mock("@/api/requests/requestRole", () => ({ requestRole: vi.fn(), })); -vi.mock('@/firebase', () => ({ +vi.mock("@/firebase", () => ({ auth: { - currentUser: { uid: 'mock-user-123' }, + currentUser: { uid: "mock-user-123" }, }, })); -vi.mock('lottie-react', () => ({ +vi.mock("lottie-react", () => ({ default: () =>
    , })); -describe('RoleRequestPage Component', () => { +describe("RoleRequestPage Component", () => { const mockNavigate = vi.fn(); let queryClient: QueryClient; beforeEach(() => { vi.clearAllMocks(); vi.mocked(useNavigate).mockReturnValue(mockNavigate); - (auth as { currentUser: any }).currentUser = { uid: 'mock-user-123' }; + (auth as { currentUser: any }).currentUser = { uid: "mock-user-123" }; queryClient = new QueryClient({ defaultOptions: { @@ -59,115 +59,154 @@ describe('RoleRequestPage Component', () => { return render( - + , ); }; - it('matches snapshot', () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); + it("matches snapshot", () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); const { container } = renderWithProviders(); expect(container).toMatchSnapshot(); }); - it('renders correctly with hardcoded organizer role and warning message', () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); + it("renders correctly with hardcoded organizer role and warning message", () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); renderWithProviders(); - expect(screen.getByText('Заявка на роль Організатора')).toBeInTheDocument(); - expect(screen.getByText(/Наразі ви можете отримати лише роль Організатора/i)).toBeInTheDocument(); - expect(screen.getByTestId('lottie-mock')).toBeInTheDocument(); + expect(screen.getByText("Заявка на роль Організатора")).toBeInTheDocument(); + expect( + screen.getByText(/Наразі ви можете отримати лише роль Організатора/i), + ).toBeInTheDocument(); + expect(screen.getByTestId("lottie-mock")).toBeInTheDocument(); }); - it('allows user to type in form fields', () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); + it("allows user to type in form fields", () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); renderWithProviders(); const nameInput = screen.getByLabelText(/ПІБ \*/i); - fireEvent.change(nameInput, { target: { value: 'Ivan Franko' } }); + fireEvent.change(nameInput, { target: { value: "Ivan Franko" } }); - expect((nameInput as HTMLInputElement).value).toBe('Ivan Franko'); + expect((nameInput as HTMLInputElement).value).toBe("Ivan Franko"); }); - it('shows error toast when user is not in redux store on submit', () => { + it("shows error toast when user is not in redux store on submit", () => { vi.mocked(useSelector).mockReturnValue(null); const { container } = renderWithProviders(); - fireEvent.submit(container.querySelector('form')!); + fireEvent.submit(container.querySelector("form")!); - expect(toast.error).toHaveBeenCalledWith('Користувач не знайдений або не авторизований!', { id: 'auth-error' }); + expect(toast.error).toHaveBeenCalledWith( + "Користувач не знайдений або не авторизований!", + { id: "auth-error" }, + ); expect(requestRole).not.toHaveBeenCalled(); }); - it('returns early and does not submit if auth.currentUser is null', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); + it("returns early and does not submit if auth.currentUser is null", async () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); (auth as { currentUser: any }).currentUser = null; const { container } = renderWithProviders(); - fireEvent.submit(container.querySelector('form')!); + fireEvent.submit(container.querySelector("form")!); await waitFor(() => { expect(requestRole).not.toHaveBeenCalled(); }); }); - it('submits form successfully with all fields and navigates to home', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); + it("submits form successfully with all fields and navigates to home", async () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); vi.mocked(requestRole).mockResolvedValue(200 as any); const { container } = renderWithProviders(); - fireEvent.change(screen.getByLabelText(/ПІБ \*/i), { target: { value: 'Tolka' } }); - fireEvent.change(screen.getByLabelText(/Email \/ Telegram \*/i), { target: { value: '@tolka' } }); - fireEvent.change(screen.getByLabelText(/Ваш вік \*/i), { target: { value: '25' } }); - fireEvent.change(screen.getByLabelText(/Чи маєте релевантний досвід\? \*/i), { target: { value: 'Yes' } }); - fireEvent.change(screen.getByLabelText(/Чому ви хочете отримати цю роль\? \*/i), { target: { value: 'Because' } }); - fireEvent.change(screen.getByLabelText(/Що ви плануєте робити на цій ролі\? \*/i), { target: { value: 'Work' } }); + fireEvent.change(screen.getByLabelText(/ПІБ \*/i), { + target: { value: "Tolka" }, + }); + fireEvent.change(screen.getByLabelText(/Email \/ Telegram \*/i), { + target: { value: "@tolka" }, + }); + fireEvent.change(screen.getByLabelText(/Ваш вік \*/i), { + target: { value: "25" }, + }); + fireEvent.change( + screen.getByLabelText(/Чи маєте релевантний досвід\? \*/i), + { target: { value: "Yes" } }, + ); + fireEvent.change( + screen.getByLabelText(/Чому ви хочете отримати цю роль\? \*/i), + { target: { value: "Because" } }, + ); + fireEvent.change( + screen.getByLabelText(/Що ви плануєте робити на цій ролі\? \*/i), + { target: { value: "Work" } }, + ); - fireEvent.submit(container.querySelector('form')!); + fireEvent.submit(container.querySelector("form")!); await waitFor(() => { - expect(requestRole).toHaveBeenCalledWith('organizer', { uid: 'mock-user-123' }, '123', [ - { option_name: 'ПІБ', value: 'Tolka' }, - { option_name: 'Контакти', value: '@tolka' }, - { option_name: 'Вік', value: '25' }, - { option_name: 'Досвід', value: 'Yes' }, - { option_name: 'Причина', value: 'Because' }, - { option_name: 'Плани', value: 'Work' }, - ]); + expect(requestRole).toHaveBeenCalledWith( + "organizer", + { uid: "mock-user-123" }, + "123", + [ + { option_name: "ПІБ", value: "Tolka" }, + { option_name: "Контакти", value: "@tolka" }, + { option_name: "Вік", value: "25" }, + { option_name: "Досвід", value: "Yes" }, + { option_name: "Причина", value: "Because" }, + { option_name: "Плани", value: "Work" }, + ], + ); }); - expect(toast.success).toHaveBeenCalledWith('Заявку відправлено! Очікуйте на відповідь', { id: 'role-submit' }); - expect(mockNavigate).toHaveBeenCalledWith('/'); + expect(toast.success).toHaveBeenCalledWith( + "Заявку відправлено! Очікуйте на відповідь", + { id: "role-submit" }, + ); + expect(mockNavigate).toHaveBeenCalledWith("/"); }); - it('shows duplicate error toast on 400 response with specific detail', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); + it("shows duplicate error toast on 400 response with specific detail", async () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); vi.mocked(requestRole).mockRejectedValue({ - response: { status: 400, data: { detail: 'Role requests already exists!' } } + response: { + status: 400, + data: { detail: "Role requests already exists!" }, + }, }); const { container } = renderWithProviders(); - fireEvent.submit(container.querySelector('form')!); + fireEvent.submit(container.querySelector("form")!); await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Ви вже подавали заявку на цю роль! Очікуйте на рішення.', { id: 'role-error' }); + expect(toast.error).toHaveBeenCalledWith( + "Ви вже подавали заявку на цю роль! Очікуйте на рішення.", + { id: "role-error" }, + ); expect(mockNavigate).not.toHaveBeenCalled(); }); }); - it('shows fallback error toast on random API failure', async () => { - vi.mocked(useSelector).mockReturnValue({ id: '123' }); - vi.mocked(requestRole).mockRejectedValue(new Error('Internal Server Error')); + it("shows fallback error toast on random API failure", async () => { + vi.mocked(useSelector).mockReturnValue({ id: "123" }); + vi.mocked(requestRole).mockRejectedValue( + new Error("Internal Server Error"), + ); const { container } = renderWithProviders(); - fireEvent.submit(container.querySelector('form')!); + fireEvent.submit(container.querySelector("form")!); await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Щось пішло не так. Спробуйте пізніше.', { id: 'role-error' }); + expect(toast.error).toHaveBeenCalledWith( + "Щось пішло не так. Спробуйте пізніше.", + { id: "role-error" }, + ); expect(mockNavigate).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 7efffa5..c091bff 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -67,7 +67,8 @@ export const Home = () => { Хочеш більше впливу на платформі?

    - Подай заявку на отримання нової ролі та розблокуй додатковий функціонал для себе та своєї команди. + Подай заявку на отримання нової ролі та розблокуй додатковий + функціонал для себе та своєї команди.

    @@ -77,8 +78,18 @@ export const Home = () => { className="flex sm:inline-flex items-center justify-center w-full sm:w-auto bg-white text-[#5c68ff] font-bold text-base md:text-lg px-6 py-3 md:px-8 md:py-4 rounded-xl hover:bg-slate-50 hover:-translate-y-1 shadow-lg hover:shadow-xl transition-all duration-300" > Отримати роль - - + +
    @@ -86,4 +97,4 @@ export const Home = () => {
    ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/Home/components/TournamentSlider.tsx b/frontend/src/pages/Home/components/TournamentSlider.tsx index 3875afe..fa8c27a 100644 --- a/frontend/src/pages/Home/components/TournamentSlider.tsx +++ b/frontend/src/pages/Home/components/TournamentSlider.tsx @@ -164,4 +164,4 @@ export const TournamentSlider = () => {
    ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/NewsPage/NewsPage.tsx b/frontend/src/pages/NewsPage/NewsPage.tsx index 27bf1b0..fc81f96 100644 --- a/frontend/src/pages/NewsPage/NewsPage.tsx +++ b/frontend/src/pages/NewsPage/NewsPage.tsx @@ -1,5 +1,5 @@ -import { type FC, useState, useEffect, useMemo } from 'react'; -import { getNews, type NewsArticle } from '@/api/requests/getNews'; +import { type FC, useState, useEffect, useMemo } from "react"; +import { getNews, type NewsArticle } from "@/api/requests/getNews"; interface ArticleReaderProps { article: NewsArticle; @@ -32,13 +32,13 @@ const InteractiveGarland: FC = () => { animate(); - window.addEventListener('resize', handleResize); - window.addEventListener('mousemove', handleMove); + window.addEventListener("resize", handleResize); + window.addEventListener("mousemove", handleMove); return () => { cancelAnimationFrame(animationFrame); - window.removeEventListener('resize', handleResize); - window.removeEventListener('mousemove', handleMove); + window.removeEventListener("resize", handleResize); + window.removeEventListener("mousemove", handleMove); }; }, []); @@ -67,16 +67,18 @@ const InteractiveGarland: FC = () => { const distance = Math.sqrt(dx * dx + dy * dy); const radius = 160; - + let mouseTilt = 0; if (distance < radius) { - const force = Math.pow((radius - distance) / radius, 1.5); - mouseTilt = -(dx / radius) * force * 50 * star.sensitivity; + const force = Math.pow((radius - distance) / radius, 1.5); + mouseTilt = -(dx / radius) * force * 50 * star.sensitivity; } const weightFactor = star.stringHeight / 80; - const idleSwing = Math.sin(time * (0.04 + weightFactor * 0.03) + star.phase) * (10 + weightFactor * 8); + const idleSwing = + Math.sin(time * (0.04 + weightFactor * 0.03) + star.phase) * + (10 + weightFactor * 8); const finalTilt = idleSwing + mouseTilt; const isHovered = distance < 60; @@ -88,8 +90,8 @@ const InteractiveGarland: FC = () => { style={{ left: `${star.leftPercent}%`, transform: `rotate(${finalTilt}deg)`, - transformOrigin: 'top center', - transition: 'transform 0.15s linear', + transformOrigin: "top center", + transition: "transform 0.15s linear", }} >
    { viewBox="0 0 24 24" className={`transition-all duration-500 ${ star.canGlow && isHovered - ? 'drop-shadow-[0_0_16px_rgba(139,92,246,0.9)] fill-[#8B5CF6] scale-125' - : 'drop-shadow-[0_0_6px_rgba(91,99,246,0.3)] fill-[#5B63F6] scale-100 opacity-80' + ? "drop-shadow-[0_0_16px_rgba(139,92,246,0.9)] fill-[#8B5CF6] scale-125" + : "drop-shadow-[0_0_6px_rgba(91,99,246,0.3)] fill-[#5B63F6] scale-100 opacity-80" }`} > @@ -118,35 +120,56 @@ const InteractiveGarland: FC = () => { export const ArticleReader: FC = ({ article, onClose }) => { useEffect(() => { - document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = 'unset'; }; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = "unset"; + }; }, []); return (
    - +
    -
    - + {article.category} - {article.date} + + {article.date} +
    -

    {article.title}

    +

    + {article.title} +

    -
    +
    @@ -156,26 +179,50 @@ export const ArticleReader: FC = ({ article, onClose }) => { export const NewsCard: FC = ({ article, onClick }) => { return ( -
    onClick(article.id)} className="group bg-white rounded-[32px] overflow-hidden border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.03)] hover:shadow-[0_12px_40px_rgba(91,99,246,0.12)] hover:-translate-y-1 transition-all duration-300 flex flex-col cursor-pointer p-8 md:p-10" >
    - {article.category} - {article.date} + + {article.category} + + + {article.date} +
    -

    {article.title}

    -

    {article.excerpt}

    +

    + {article.title} +

    +

    + {article.excerpt} +

    Читати повністю - + + +
    ); }; export const NewsPage: FC = () => { - const [selectedArticleId, setSelectedArticleId] = useState(null); + const [selectedArticleId, setSelectedArticleId] = useState( + null, + ); const [newsArticles, setNewsArticles] = useState([]); const [loading, setLoading] = useState(true); @@ -188,7 +235,7 @@ export const NewsPage: FC = () => { .finally(() => setLoading(false)); }, []); - const activeArticle = newsArticles.find(a => a.id === selectedArticleId); + const activeArticle = newsArticles.find((a) => a.id === selectedArticleId); return (
    @@ -199,7 +246,8 @@ export const NewsPage: FC = () => { Журнал Подій

    - Найважливіше зі світу UGalaxy. Анонси турнірів, історії волонтерів та оновлення платформи. + Найважливіше зі світу UGalaxy. Анонси турнірів, історії волонтерів та + оновлення платформи.

    @@ -207,17 +255,28 @@ export const NewsPage: FC = () => { {loading ? (
    Завантаження...
    ) : newsArticles.length === 0 ? ( -
    Новин поки що немає
    +
    + Новин поки що немає +
    ) : (
    {newsArticles.map((article) => ( - + ))}
    )} - {activeArticle && setSelectedArticleId(null)} />} + {activeArticle && ( + setSelectedArticleId(null)} + /> + )}
    ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/OrganizerPanel/CreateTournament.test.tsx b/frontend/src/pages/OrganizerPanel/CreateTournament.test.tsx index 5d88e0d..8045f9d 100644 --- a/frontend/src/pages/OrganizerPanel/CreateTournament.test.tsx +++ b/frontend/src/pages/OrganizerPanel/CreateTournament.test.tsx @@ -14,20 +14,30 @@ describe("CreateTournamentModal", () => { const renderModal = () => { const user = userEvent.setup(); const view = render( - + , ); return { user, ...view }; }; - const getForm = () => document.getElementById("create-tournament-form") as HTMLFormElement; - const getBackdrop = () => document.querySelector(".bg-slate-900\\/40") as HTMLDivElement; + const getForm = () => + document.getElementById("create-tournament-form") as HTMLFormElement; + const getBackdrop = () => + document.querySelector(".bg-slate-900\\/40") as HTMLDivElement; // --- Rendering (Small) --- describe("Rendering", () => { it("does not render anything when isOpen is false", () => { const { container } = render( - + , ); expect(container.firstChild).toBeNull(); }); @@ -35,21 +45,35 @@ describe("CreateTournamentModal", () => { it("renders the modal dialog when isOpen is true", () => { renderModal(); expect(screen.getByText("НОВИЙ ТУРНІР")).toBeInTheDocument(); - expect(screen.getByText("СТВОРЕННЯ НОВОЇ ПОДІЇ У ВСЕСВІТІ")).toBeInTheDocument(); + expect( + screen.getByText("СТВОРЕННЯ НОВОЇ ПОДІЇ У ВСЕСВІТІ"), + ).toBeInTheDocument(); }); it("renders all form text inputs", () => { renderModal(); - expect(screen.getByPlaceholderText("Введіть круту назву...")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("Про що цей турнір?")).toBeInTheDocument(); - expect(document.querySelector('input[name="max_teams"]')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Введіть круту назву..."), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Про що цей турнір?"), + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="max_teams"]'), + ).toBeInTheDocument(); }); it("renders all datetime inputs", () => { renderModal(); - expect(document.querySelector('input[name="reg_start"]')).toBeInTheDocument(); - expect(document.querySelector('input[name="reg_end"]')).toBeInTheDocument(); - expect(document.querySelector('input[name="start_date"]')).toBeInTheDocument(); + expect( + document.querySelector('input[name="reg_start"]'), + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="reg_end"]'), + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="start_date"]'), + ).toBeInTheDocument(); }); it("renders control buttons with correct initial text", () => { @@ -66,7 +90,7 @@ describe("CreateTournamentModal", () => { it("updates title input value correctly", async () => { const { user } = renderModal(); const titleInput = screen.getByPlaceholderText("Введіть круту назву..."); - + await user.type(titleInput, "Super Cup"); expect(titleInput).toHaveValue("Super Cup"); }); @@ -74,23 +98,29 @@ describe("CreateTournamentModal", () => { it("updates description input value correctly", async () => { const { user } = renderModal(); const descInput = screen.getByPlaceholderText("Про що цей турнір?"); - + await user.type(descInput, "Test description"); expect(descInput).toHaveValue("Test description"); }); it("updates datetime inputs correctly via fireEvent", () => { renderModal(); - const regStartInput = document.querySelector('input[name="reg_start"]') as HTMLInputElement; - - fireEvent.change(regStartInput, { target: { name: "reg_start", value: "2026-06-01T12:00" } }); + const regStartInput = document.querySelector( + 'input[name="reg_start"]', + ) as HTMLInputElement; + + fireEvent.change(regStartInput, { + target: { name: "reg_start", value: "2026-06-01T12:00" }, + }); expect(regStartInput.value).toBe("2026-06-01T12:00"); }); it("parses max_teams as a number", async () => { const { user } = renderModal(); - const maxTeamsInput = document.querySelector('input[name="max_teams"]') as HTMLInputElement; - + const maxTeamsInput = document.querySelector( + 'input[name="max_teams"]', + ) as HTMLInputElement; + await user.clear(maxTeamsInput); await user.type(maxTeamsInput, "32"); expect(maxTeamsInput.value).toBe("32"); @@ -125,19 +155,33 @@ describe("CreateTournamentModal", () => { const fillValidForm = async (user: any) => { const titleInput = screen.getByPlaceholderText("Введіть круту назву..."); const descInput = screen.getByPlaceholderText("Про що цей турнір?"); - const maxTeamsInput = document.querySelector('input[name="max_teams"]') as HTMLInputElement; - const regStartInput = document.querySelector('input[name="reg_start"]') as HTMLInputElement; - const regEndInput = document.querySelector('input[name="reg_end"]') as HTMLInputElement; - const startDateInput = document.querySelector('input[name="start_date"]') as HTMLInputElement; + const maxTeamsInput = document.querySelector( + 'input[name="max_teams"]', + ) as HTMLInputElement; + const regStartInput = document.querySelector( + 'input[name="reg_start"]', + ) as HTMLInputElement; + const regEndInput = document.querySelector( + 'input[name="reg_end"]', + ) as HTMLInputElement; + const startDateInput = document.querySelector( + 'input[name="start_date"]', + ) as HTMLInputElement; await user.type(titleInput, "Valid Tournament"); await user.type(descInput, "Valid Description"); await user.clear(maxTeamsInput); await user.type(maxTeamsInput, "16"); - fireEvent.change(regStartInput, { target: { name: "reg_start", value: "2026-05-01T10:00" } }); - fireEvent.change(regEndInput, { target: { name: "reg_end", value: "2026-05-15T10:00" } }); - fireEvent.change(startDateInput, { target: { name: "start_date", value: "2026-05-20T10:00" } }); + fireEvent.change(regStartInput, { + target: { name: "reg_start", value: "2026-05-01T10:00" }, + }); + fireEvent.change(regEndInput, { + target: { name: "reg_end", value: "2026-05-15T10:00" }, + }); + fireEvent.change(startDateInput, { + target: { name: "start_date", value: "2026-05-20T10:00" }, + }); }; it("submits the form successfully with correct data formatting", async () => { @@ -162,19 +206,31 @@ describe("CreateTournamentModal", () => { it("trims whitespace from title and description before submitting", async () => { const { user } = renderModal(); - + const titleInput = screen.getByPlaceholderText("Введіть круту назву..."); const descInput = screen.getByPlaceholderText("Про що цей турнір?"); await user.type(titleInput, " Spaced Title "); await user.type(descInput, " Spaced Desc "); - const regStartInput = document.querySelector('input[name="reg_start"]') as HTMLInputElement; - const regEndInput = document.querySelector('input[name="reg_end"]') as HTMLInputElement; - const startDateInput = document.querySelector('input[name="start_date"]') as HTMLInputElement; - - fireEvent.change(regStartInput, { target: { name: "reg_start", value: "2026-05-01T10:00" } }); - fireEvent.change(regEndInput, { target: { name: "reg_end", value: "2026-05-15T10:00" } }); - fireEvent.change(startDateInput, { target: { name: "start_date", value: "2026-05-20T10:00" } }); + const regStartInput = document.querySelector( + 'input[name="reg_start"]', + ) as HTMLInputElement; + const regEndInput = document.querySelector( + 'input[name="reg_end"]', + ) as HTMLInputElement; + const startDateInput = document.querySelector( + 'input[name="start_date"]', + ) as HTMLInputElement; + + fireEvent.change(regStartInput, { + target: { name: "reg_start", value: "2026-05-01T10:00" }, + }); + fireEvent.change(regEndInput, { + target: { name: "reg_end", value: "2026-05-15T10:00" }, + }); + fireEvent.change(startDateInput, { + target: { name: "start_date", value: "2026-05-20T10:00" }, + }); fireEvent.submit(getForm()); @@ -183,7 +239,7 @@ describe("CreateTournamentModal", () => { expect.objectContaining({ title: "Spaced Title", description: "Spaced Desc", - }) + }), ); }); }); @@ -209,7 +265,13 @@ describe("CreateTournamentModal", () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); - rerender(); + rerender( + , + ); const titleInput = screen.getByPlaceholderText("Введіть круту назву..."); expect(titleInput).toHaveValue(""); @@ -221,27 +283,42 @@ describe("CreateTournamentModal", () => { describe("Loading and Error States", () => { it("disables inputs and buttons while submission is in progress", async () => { let resolvePromise: any; - mockOnCreate.mockImplementation(() => new Promise(resolve => { - resolvePromise = resolve; - })); + mockOnCreate.mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); renderModal(); - - const regStartInput = document.querySelector('input[name="reg_start"]') as HTMLInputElement; - const regEndInput = document.querySelector('input[name="reg_end"]') as HTMLInputElement; - const startDateInput = document.querySelector('input[name="start_date"]') as HTMLInputElement; - fireEvent.change(regStartInput, { target: { name: "reg_start", value: "2026-05-01T10:00" } }); - fireEvent.change(regEndInput, { target: { name: "reg_end", value: "2026-05-15T10:00" } }); - fireEvent.change(startDateInput, { target: { name: "start_date", value: "2026-05-20T10:00" } }); + const regStartInput = document.querySelector( + 'input[name="reg_start"]', + ) as HTMLInputElement; + const regEndInput = document.querySelector( + 'input[name="reg_end"]', + ) as HTMLInputElement; + const startDateInput = document.querySelector( + 'input[name="start_date"]', + ) as HTMLInputElement; + + fireEvent.change(regStartInput, { + target: { name: "reg_start", value: "2026-05-01T10:00" }, + }); + fireEvent.change(regEndInput, { + target: { name: "reg_end", value: "2026-05-15T10:00" }, + }); + fireEvent.change(startDateInput, { + target: { name: "start_date", value: "2026-05-20T10:00" }, + }); fireEvent.submit(getForm()); expect(screen.getByText("СТВОРЕННЯ...")).toBeInTheDocument(); - + const submitBtn = screen.getByText("СТВОРЕННЯ...") as HTMLButtonElement; const cancelBtn = screen.getByText("СКАСУВАТИ") as HTMLButtonElement; - + expect(submitBtn).toBeDisabled(); expect(cancelBtn).toBeDisabled(); @@ -253,27 +330,46 @@ describe("CreateTournamentModal", () => { }); it("logs error to console and stops loading if onCreate throws", async () => { - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); mockOnCreate.mockRejectedValue(new Error("Network Error")); renderModal(); - - const regStartInput = document.querySelector('input[name="reg_start"]') as HTMLInputElement; - const regEndInput = document.querySelector('input[name="reg_end"]') as HTMLInputElement; - const startDateInput = document.querySelector('input[name="start_date"]') as HTMLInputElement; - fireEvent.change(regStartInput, { target: { name: "reg_start", value: "2026-05-01T10:00" } }); - fireEvent.change(regEndInput, { target: { name: "reg_end", value: "2026-05-15T10:00" } }); - fireEvent.change(startDateInput, { target: { name: "start_date", value: "2026-05-20T10:00" } }); + const regStartInput = document.querySelector( + 'input[name="reg_start"]', + ) as HTMLInputElement; + const regEndInput = document.querySelector( + 'input[name="reg_end"]', + ) as HTMLInputElement; + const startDateInput = document.querySelector( + 'input[name="start_date"]', + ) as HTMLInputElement; + + fireEvent.change(regStartInput, { + target: { name: "reg_start", value: "2026-05-01T10:00" }, + }); + fireEvent.change(regEndInput, { + target: { name: "reg_end", value: "2026-05-15T10:00" }, + }); + fireEvent.change(startDateInput, { + target: { name: "start_date", value: "2026-05-20T10:00" }, + }); fireEvent.submit(getForm()); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith("Помилка при створенні турніру:", expect.any(Error)); + expect(consoleSpy).toHaveBeenCalledWith( + "Помилка при створенні турніру:", + expect.any(Error), + ); }); expect(screen.getByText("СТВОРИТИ ТУРНІР")).toBeInTheDocument(); - const submitBtn = screen.getByText("СТВОРИТИ ТУРНІР") as HTMLButtonElement; + const submitBtn = screen.getByText( + "СТВОРИТИ ТУРНІР", + ) as HTMLButtonElement; expect(submitBtn).not.toBeDisabled(); expect(mockOnClose).not.toHaveBeenCalled(); @@ -281,4 +377,4 @@ describe("CreateTournamentModal", () => { consoleSpy.mockRestore(); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/pages/OrganizerPanel/CreateTournamentModal.tsx b/frontend/src/pages/OrganizerPanel/CreateTournamentModal.tsx index cb24780..279ec37 100644 --- a/frontend/src/pages/OrganizerPanel/CreateTournamentModal.tsx +++ b/frontend/src/pages/OrganizerPanel/CreateTournamentModal.tsx @@ -16,10 +16,10 @@ interface FormData { max_teams: number; } -export const CreateTournamentModal: React.FC = ({ - isOpen, - onClose, - onCreate +export const CreateTournamentModal: React.FC = ({ + isOpen, + onClose, + onCreate, }) => { const initialState: FormData = { title: "", @@ -35,11 +35,13 @@ export const CreateTournamentModal: React.FC = ({ if (!isOpen) return null; - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent, + ) => { const { name, value } = e.target; - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - [name]: name === "max_teams" ? Number(value) : value + [name]: name === "max_teams" ? Number(value) : value, })); }; @@ -74,92 +76,110 @@ export const CreateTournamentModal: React.FC = ({ return (
    -
    - +
    -
    - -

    НОВИЙ ТУРНІР

    +

    + НОВИЙ ТУРНІР +

    СТВОРЕННЯ НОВОЇ ПОДІЇ У ВСЕСВІТІ

    -
    - +
    - - + Назва турніру + +
    - - - -
    + +
    + + + {errors.fullName && ( + + {t(errors.fullName.message as string)} + + )} +
    -
    - - -
    +
    +
    + + + {errors.contact && ( + + {t(errors.contact.message as string)} + + )} +
    -
    - - -
    +
    + + + {errors.age && ( + + {t(errors.age.message as string)} + + )} +
    +
    - -
    -
    +
    +
    + +
    + {renderCharCounter(expLen)} +
    +
    +