<%= @calculator.name %>
+<%= @calculator.slug %>
+diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cea3e33d5..bc09fe086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,16 @@ name: CI on: push: + pull_request: + branches: + - develop + - master + types: + - closed + + release: + types: [published] + jobs: rubocop: runs-on: ubuntu-latest @@ -50,7 +60,10 @@ jobs: with: ruby-version: 3.3.5 bundler-cache: true - + + - name: Update packages + run: sudo apt-get update + - name: Install system dependencies run: | sudo apt-get update @@ -58,8 +71,8 @@ jobs: - uses: actions/setup-node@v1 with: - node-version: '14.x' - registry-url: 'https://registry.npmjs.org' + node-version: "14.x" + registry-url: "https://registry.npmjs.org" - uses: nanasess/setup-chromedriver@master @@ -81,3 +94,40 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} json-path: tmp/rspec_results.json if: always() + + deploy-to-staging: + needs: rspec + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true + steps: + - uses: actions/checkout@v2 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.5 + bundler-cache: true + + - uses: miloserdow/capistrano-deploy@v3 + with: + target: staging + deploy_key: ${{ secrets.STAGING_KEY_PASSWORD }} + enc_rsa_key_pth: config/credentials/staging_deploy_id_ed25519_enc + + deploy-to-production: + needs: rspec + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + steps: + - uses: actions/checkout@v2 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.5 + bundler-cache: true + + - uses: miloserdow/capistrano-deploy@v3 + with: + target: production + deploy_key: ${{ secrets.PROD_DEPLOY_KEY }} diff --git a/.rubocop.yml b/.rubocop.yml index 51dec8a54..0d435ded4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,7 +18,7 @@ inherit_gem: AllCops: SuggestExtensions: true NewCops: enable - TargetRubyVersion: 3.0.6 + TargetRubyVersion: 3.2 Layout/SpaceInsideHashLiteralBraces: Enabled: true diff --git a/README.md b/README.md index d1bb70b38..0f6a65d4f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The latest version from the release branch 'master' is automatically deployed to - Bootstrap ## Clone - + $ `git clone https://github.com/ita-social-projects/ZeroWaste.git` ## Local setup diff --git a/app/assets/images/pad_scales.png b/app/assets/images/pad_scales.png new file mode 100644 index 000000000..bae343088 Binary files /dev/null and b/app/assets/images/pad_scales.png differ diff --git a/app/assets/images/pads_bought.png b/app/assets/images/pads_bought.png new file mode 100644 index 000000000..7a864b55a Binary files /dev/null and b/app/assets/images/pads_bought.png differ diff --git a/app/assets/images/pads_to_buy.png b/app/assets/images/pads_to_buy.png new file mode 100644 index 000000000..0fcf41cda Binary files /dev/null and b/app/assets/images/pads_to_buy.png differ diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index f73f37afc..9a85acf0b 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -10,5 +10,6 @@ @import "/components/pagination"; @import "/components/breadcrumbs.scss"; @import "/components/description_block"; +@import "/components/showpage_calculator"; @import "/utilities/custom-utilities"; @import "/pages/under_construction" diff --git a/app/assets/stylesheets/components/showpage_calculator.css b/app/assets/stylesheets/components/showpage_calculator.css new file mode 100644 index 000000000..c106a4e6e --- /dev/null +++ b/app/assets/stylesheets/components/showpage_calculator.css @@ -0,0 +1,21 @@ +@layer components { + .main-show-container { + @apply flex flex-col bg-white rounded-lg shadow-md w-full p-6 text-left mt-4; + } + + .back-arrow { + @apply rounded mb-4 px-2 flex items-center; + } + + .calc-details { + @apply flex flex-col mb-4 px-2; + } + + .showpage-buttons { + @apply flex justify-start w-full space-x-4; + } + + .showpage-text { + @apply text-slate-600 text-sm; + } +} diff --git a/app/assets/stylesheets/pages/calculator.scss b/app/assets/stylesheets/pages/calculator.scss index 0dede5e06..1c8178664 100644 --- a/app/assets/stylesheets/pages/calculator.scss +++ b/app/assets/stylesheets/pages/calculator.scss @@ -16,6 +16,28 @@ font-style: normal; transition: transform 0.5s ease-in-out; max-width: 300px; + @include transition(all 0.5s ease-in-out); + + &:hover { + background-color: $matte_lime_green; + } +} + +.btn-nonito { + font-size: 14px; + letter-spacing: 2px; + text-transform: uppercase; + font-weight: 400; + font-family: "Nunito", sans-serif; + font-style: normal; +} + +.dynamic-text-color { + color: var(--calculator-color); +} + +.dynamic-background-color { + background-color: var(--calculator-color); } #calc { @@ -61,6 +83,11 @@ margin-right: 0px; } +.calculator-field { + background-color: $light_gray !important; + border: 0; +} + .flex-item { display: flex; flex-wrap: wrap; diff --git a/app/assets/stylesheets/pages/feature_flags.scss b/app/assets/stylesheets/pages/feature_flags.scss index a68714537..53a8ce168 100644 --- a/app/assets/stylesheets/pages/feature_flags.scss +++ b/app/assets/stylesheets/pages/feature_flags.scss @@ -41,7 +41,6 @@ } input[type="submit"] { - background-color: $success; border: none; border-radius: 4px; color: #fff; @@ -49,11 +48,6 @@ input[type="submit"] { font-size: 16px; padding: 10px; min-width: 110px; - @include transition(all 0.5s ease-in-out); - - &:hover { - background-color: $matte_lime_green; - } } .btn-grey { diff --git a/app/controllers/account/calculators_controller.rb b/app/controllers/account/calculators_controller.rb index 4a1cd4b6a..c4d6bb1a5 100644 --- a/app/controllers/account/calculators_controller.rb +++ b/app/controllers/account/calculators_controller.rb @@ -2,10 +2,9 @@ class Account::CalculatorsController < Account::BaseController load_and_authorize_resource + before_action :check_constructor_flipper def index - render "shared/under_construction" unless Rails.env.local? - @q = collection.ransack(params[:q]) @calculators = @q.result.page(params[:page]) end @@ -95,4 +94,10 @@ def updater @calculator.update(calculator_params) end end + + def check_constructor_flipper + return if Flipper[:constructor_status].enabled? + + raise ActionController::RoutingError, "Constructor flipper is disabled" + end end diff --git a/app/controllers/account/users_controller.rb b/app/controllers/account/users_controller.rb index e36afe64d..4fc589e74 100644 --- a/app/controllers/account/users_controller.rb +++ b/app/controllers/account/users_controller.rb @@ -6,6 +6,7 @@ class Account::UsersController < Account::BaseController layout "account" before_action :set_paper_trail_whodunnit + before_action :blocking_admin, only: :update load_and_authorize_resource @@ -71,6 +72,16 @@ def user_params prms end + def blocking_admin + @user = resource + + return if params.dig(:user, :blocked).blank? || !@user.admin? + + flash[:alert] = t("errors.messages.blocked_user_cannot_be_admin") + + redirect_to account_users_path + end + def collection User.ordered_by_email end diff --git a/app/controllers/api/v1/pad_calculators_controller.rb b/app/controllers/api/v1/pad_calculators_controller.rb new file mode 100644 index 000000000..659282f88 --- /dev/null +++ b/app/controllers/api/v1/pad_calculators_controller.rb @@ -0,0 +1,20 @@ +class Api::V1::PadCalculatorsController < ApplicationController + def calculate + @validation = MhcCalculatorValidator.new(params) + + if @validation.valid? + calc_service = Calculators::PadUsageService.new( + user_age: params[:user_age], + menstruation_age: params[:menstruation_age], + menopause_age: params[:menopause_age], + average_menstruation_cycle_duration: params[:average_menstruation_cycle_duration], + pads_per_cycle: params[:pads_per_cycle], + pad_category: params[:pad_category] + ) + + render json: calc_service.calculate, status: :ok + else + render json: { errors: @validation.errors }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/calculators_controller.rb b/app/controllers/calculators_controller.rb index 8e87d0e92..a6b025e2c 100644 --- a/app/controllers/calculators_controller.rb +++ b/app/controllers/calculators_controller.rb @@ -3,6 +3,9 @@ class CalculatorsController < ApplicationController before_action :authenticate_user!, only: :receive_recomendations + before_action :check_constructor_flipper, only: [:index, :show, :calculate] + before_action :check_mhc_flipper, only: :mhc_calculator + def index if Flipper[:show_calculators_list].enabled? @q = collection.ransack(params[:q]) @@ -14,6 +17,8 @@ def index def show @calculator = resource + add_breadcrumb t("breadcrumbs.home"), root_path + add_breadcrumb @calculator.name end def calculate @@ -37,6 +42,11 @@ def calculator end end + def mhc_calculator + add_breadcrumb t("breadcrumbs.home"), root_path + add_breadcrumb t(".mhc_calculator.calculator_name") + end + def receive_recomendations current_user.toggle(:receive_recomendations) current_user.save @@ -51,4 +61,16 @@ def collection def resource collection.friendly.find(params[:slug]) end + + def check_constructor_flipper + return if Flipper[:constructor_status].enabled? + + raise ActionController::RoutingError, "Constructor flipper is disabled" + end + + def check_mhc_flipper + return if Flipper[:mhc_calculator_status].enabled? + + raise ActionController::RoutingError, "Mhc calculator flipper is disabled" + end end diff --git a/app/helpers/calculators_helper.rb b/app/helpers/calculators_helper.rb index 7d3236c88..1ce08246d 100644 --- a/app/helpers/calculators_helper.rb +++ b/app/helpers/calculators_helper.rb @@ -31,6 +31,15 @@ def link_to_external(text:, url:, **options) end end + def mhc_calculator_items + [{ image: "pads_bought.png", data_target: "padsUsed", unit: t(".pieces"), text: t(".bought_products") }, + "arrow", + { image: "pads_to_buy.png", data_target: "padsToBeUsed", unit: t(".pieces"), text: t(".will_buy_products") }, + { image: "money_spent_2.png", data_target: "moneySpent", unit: t(".unit"), text: t(".money_spent") }, + "arrow", + { image: "money_to_spent_2.png", data_target: "moneyWillBeSpent", unit: t(".unit"), text: t(".money_will_be_spent") }] + end + def new_calculator_items [{ image: "diapers_bought_2.png", data_target: "diapersUsed", unit: t(".pieces"), text_target: "boughtDiapersPluralize", text: t(".bought_diapers", count: 0) }, "arrow", diff --git a/app/javascript/controllers/constructors_form_indexing_controller.js b/app/javascript/controllers/constructors_form_indexing_controller.js new file mode 100644 index 000000000..9e5677b96 --- /dev/null +++ b/app/javascript/controllers/constructors_form_indexing_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="constructors-form-indexing" +export default class extends Controller { + static targets = ["index"]; + + afterInsert(event) { + const fieldsets = this.element.querySelectorAll(":scope > .nested-fields"); + const span = fieldsets[fieldsets.length - 1].querySelector("[data-constructors-form-indexing-target='index']") + + if (span) { + span.textContent = `${fieldsets.length}`; + } + } + + afterRemove(event) { + const fieldsets = this.element.querySelectorAll(":scope > .nested-fields"); + + fieldsets.forEach((fieldset, index) => { + const span = fieldset.querySelector("[data-constructors-form-indexing-target='index']"); + if (span) { + span.textContent = `${index + 1}`; + } + }); + } +} diff --git a/app/javascript/controllers/mhc_calculator_controller.js b/app/javascript/controllers/mhc_calculator_controller.js new file mode 100644 index 000000000..dc7e0f3ad --- /dev/null +++ b/app/javascript/controllers/mhc_calculator_controller.js @@ -0,0 +1,71 @@ +import { Controller } from "@hotwired/stimulus"; +import { FetchRequest } from "@rails/request.js"; + +export default class extends Controller { + static targets = ["userAge", "menstruationAge", "menopauseAge", "averageMenstruationCycleDuration", "padsPerCycle", "padCategory"]; + static outlets = ["pad-results"]; + static values = { + url: { + type: String, + default: "en/api/v1/pad_calculators", + } + }; + + submit(e) { + e.preventDefault(); + + let formData = { + user_age: parseInt(this.userAgeTarget.value), + menstruation_age: parseInt(this.menstruationAgeTarget.value), + menopause_age: parseInt(this.menopauseAgeTarget.value), + average_menstruation_cycle_duration: parseInt(this.averageMenstruationCycleDurationTarget.value), + pads_per_cycle: parseInt(this.padsPerCycleTarget.value), + pad_category: this.padCategoryTarget.value + }; + + const request = new FetchRequest("POST", this.urlValue, { + responseKind: "json", + body: JSON.stringify(formData), + }); + + this.sendRequest(request); + } + + async sendRequest(request) { + const response = await request.perform(); + const result = await response.json; + + this.clearErrors() + + if (response.ok) { + this.padResultsOutlet.showResults(result); + } else if (response.statusCode == 422) { + this.showErrors(result.errors); + } + } + + showErrors(errors) { + Object.keys(errors).forEach(errorKey => { + const targetKey = errorKey.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); + const feedbackDiv = document.createElement('div'); + + feedbackDiv.className = 'invalid-feedback'; + feedbackDiv.textContent = errors[errorKey]; + + this[`${targetKey}Target`].classList.add("is-invalid"); + this[`${targetKey}Target`].insertAdjacentElement('afterend', feedbackDiv); + }); + } + + clearErrors(){ + this.constructor.targets.forEach(targetKey => { + const targetElement = this[`${targetKey}Target`]; + targetElement.classList.remove("is-invalid"); + + const feedbackDiv = targetElement.nextElementSibling; + if (feedbackDiv && feedbackDiv.classList.contains('invalid-feedback')) { + feedbackDiv.remove(); + } + }); + } +} diff --git a/app/javascript/controllers/pad_results_controller.js b/app/javascript/controllers/pad_results_controller.js new file mode 100644 index 000000000..a998666df --- /dev/null +++ b/app/javascript/controllers/pad_results_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "padsUsed", + "padsToBeUsed", + "moneySpent", + "moneyWillBeSpent" + ]; + + showResults(data) { + let result = data; + + this.moneySpentTarget.innerHTML = Math.ceil(result.already_used_products_cost); + this.moneyWillBeSpentTarget.innerHTML = Math.ceil(result.products_to_be_used_cost); + this.padsUsedTarget.innerHTML = Math.ceil(result.already_used_products); + this.padsToBeUsedTarget.innerHTML = Math.ceil(result.products_to_be_used); + + this.element.scrollIntoView({ behavior: "smooth" }); + } +} diff --git a/app/javascript/controllers/price_form_controller.js b/app/javascript/controllers/price_form_controller.js index 9ecdf8479..8e2262e64 100644 --- a/app/javascript/controllers/price_form_controller.js +++ b/app/javascript/controllers/price_form_controller.js @@ -13,6 +13,7 @@ export default class extends Controller { this.priceInputTargets.forEach(input => { input.addEventListener('input', this.validatePriceInput.bind(this)); + input.addEventListener('keydown', this.restrictDecimalInput.bind(this)); }); } @@ -57,4 +58,13 @@ export default class extends Controller { target.style.borderColor = ""; } } + + restrictDecimalInput(event) { + const target = event.target; + const inputValue = target.value; + + if (inputValue.includes('.') && inputValue.split('.')[1].length >= 2 && !["Backspace", "Delete"].includes(event.key)) { + event.preventDefault(); + } + } } diff --git a/app/javascript/controllers/results_controller.js b/app/javascript/controllers/results_controller.js index 2fc4578ed..f66dbcc75 100644 --- a/app/javascript/controllers/results_controller.js +++ b/app/javascript/controllers/results_controller.js @@ -20,5 +20,7 @@ export default class extends Controller { this.willBuyDiapersPluralizeTarget.innerHTML = result.to_be_diapers_amount_pluralize; this.boughtDiapersPluralizeTarget.innerHTML = result.used_diapers_amount_pluralize; + + this.element.scrollIntoView({ behavior: "smooth" }); } } diff --git a/app/models/formula.rb b/app/models/formula.rb index 66b8217bd..872682229 100644 --- a/app/models/formula.rb +++ b/app/models/formula.rb @@ -22,6 +22,8 @@ # fk_rails_... (calculator_id => calculators.id) # class Formula < ApplicationRecord + include Translatable + belongs_to :calculator PRIORITY_RANGE = 0..10 @@ -34,4 +36,6 @@ class Formula < ApplicationRecord validates :priority, numericality: { greater_than_or_equal_to: 0 } scope :ordered_by_priority, -> { order(:priority) } + + translates :label, :unit end diff --git a/app/services/calculators/calculation_service.rb b/app/services/calculators/calculation_service.rb index 081092b0f..e888d1f47 100644 --- a/app/services/calculators/calculation_service.rb +++ b/app/services/calculators/calculation_service.rb @@ -10,7 +10,7 @@ def perform @calculator.formulas.map do |formula| result = @dentaku.evaluate(formula.expression, @inputs) - { label: formula.en_label, result: result } + { label: formula.label, result: result, unit: formula.unit } end end end diff --git a/app/services/calculators/pad_usage_service.rb b/app/services/calculators/pad_usage_service.rb new file mode 100644 index 000000000..5de628434 --- /dev/null +++ b/app/services/calculators/pad_usage_service.rb @@ -0,0 +1,52 @@ +class Calculators::PadUsageService + attr_accessor :user_age, :menstruation_age, :menopause_age, + :average_menstruation_cycle_duration, + :pads_per_cycle, :pad_category + + PAD_PRICES = { + budget: 2, + average: 4, + premium: 7 + } + + def initialize(user_age:, menstruation_age:, menopause_age:, average_menstruation_cycle_duration:, + pads_per_cycle:, pad_category:) + @user_age = user_age + @menstruation_age = menstruation_age + @menopause_age = menopause_age || 48.7 + @average_menstruation_cycle_duration = average_menstruation_cycle_duration + @pads_per_cycle = pads_per_cycle + @pad_category = (pad_category || :budget).to_sym + end + + def calculate + { + already_used_products:, + already_used_products_cost:, + products_to_be_used:, + products_to_be_used_cost: + } + end + + private + + def already_used_products + menstruations_from_age_range(menstruation_age, user_age) * pads_per_cycle + end + + def products_to_be_used + menstruations_from_age_range(user_age, menopause_age) * pads_per_cycle + end + + def already_used_products_cost + already_used_products * PAD_PRICES[pad_category] + end + + def products_to_be_used_cost + products_to_be_used * PAD_PRICES[pad_category] + end + + def menstruations_from_age_range(from_age, till_age) + (till_age - from_age) * (365 / average_menstruation_cycle_duration) + end +end diff --git a/app/validators/mhc_calculator_validator.rb b/app/validators/mhc_calculator_validator.rb new file mode 100644 index 000000000..cd5e21bfe --- /dev/null +++ b/app/validators/mhc_calculator_validator.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class MhcCalculatorValidator + attr_reader :params, :errors + + def initialize(params) + @params = params + @errors = {} + end + + def valid? + validate_user_age + validate_menstruation_age + validate_menopause_age + validate_average_menstruation_cycle_duration + validate_pads_per_cycle + validate_pad_category + + errors.empty? + end + + private + + def validate_user_age + presence_valid?(:user_age) + end + + def validate_menstruation_age + presence_valid?(:menstruation_age) + end + + def validate_menopause_age + presence_valid?(:menopause_age) + end + + def validate_average_menstruation_cycle_duration + presence_valid?(:average_menstruation_cycle_duration) + end + + def validate_pads_per_cycle + presence_valid?(:pads_per_cycle) + end + + def validate_pad_category + presence_valid?(:pad_category) + end + + def presence_valid?(param) + return true if @params[param].present? + + @errors[param] = I18n.t("calculators.errors.presence_error_msg", field: I18n.t("calculators.mhc_calculator.form.#{param}")) + + false + end +end diff --git a/app/views/account/calculators/partials/_category_fields.html.erb b/app/views/account/calculators/partials/_category_fields.html.erb index b90a7a26d..ab81bb659 100644 --- a/app/views/account/calculators/partials/_category_fields.html.erb +++ b/app/views/account/calculators/partials/_category_fields.html.erb @@ -1,7 +1,10 @@ -
<%= @calculator.name %>
+<%= @calculator.slug %>
+0
+<%= item[:unit] %>
+<%= item[:text] %>
+- <%= result[:result] %> -
-+ <%= result[:result] %> +
++ <%= result[:unit] %> +
++ <%= result[:label] %> +
+