diff --git a/Gemfile b/Gemfile index 164813f..55ea2e0 100644 --- a/Gemfile +++ b/Gemfile @@ -39,3 +39,5 @@ group :test do gem "capybara" gem "selenium-webdriver" end + +gem "faraday-retry", "~> 2.3" diff --git a/Gemfile.lock b/Gemfile.lock index d753e56..c32af57 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,6 +129,8 @@ GEM logger faraday-net_http (3.4.0) net-http (>= 0.5.0) + faraday-retry (2.3.1) + faraday (~> 2.0) ffi (1.17.1-x86_64-linux-gnu) globalid (1.2.1) activesupport (>= 6.1) @@ -369,6 +371,7 @@ DEPENDENCIES devise (~> 4.9) dotenv-rails faker (~> 3.5, >= 3.5.1) + faraday-retry (~> 2.3) importmap-rails jbuilder jquery-rails diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 79aef22..53213a6 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,7 +1,8 @@ -//= link_tree ../images //= link_tree ../builds //= link_tree ../../javascript .js //= link_tree ../../../vendor/javascript .js //= link_directory ../stylesheets .scss -//= link application.css -// = link jquery.min.js +//= link jquery.min.js +//= link application.scss +//= link badge/style.scss +//= link nav.scss diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index b673a46..a7e81b0 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,4 +1,4 @@ -@import "bootstrap"; - +@import "../node_modules/bootstrap/scss/bootstrap"; @import "typography/icons"; @import "global"; +@import "badge/style"; diff --git a/app/assets/stylesheets/badge/style.scss b/app/assets/stylesheets/badge/style.scss new file mode 100644 index 0000000..d5502c8 --- /dev/null +++ b/app/assets/stylesheets/badge/style.scss @@ -0,0 +1,44 @@ +.earned-badge { + position: relative; + border: 2px solid #FFD700; + transition: all 0.3s ease; +} + +.earned-badge:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(255, 215, 0, 0.2); +} + +.earned-ribbon { + position: absolute; + top: 10px; + right: -10px; + background: #FFD700; + color: #000; + padding: 3px 15px; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + z-index: 1; +} + +.earned-ribbon:before { + content: ''; + position: absolute; + bottom: -10px; + right: 0; + border-left: 10px solid transparent; + border-right: 10px solid #c90; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; +} + +.badge-icon { + max-height: 100px; + filter: drop-shadow(0 2px 5px rgba(0,0,0,0.2)); +} + +.grayscale { + filter: grayscale(100%) brightness(0.7); +} \ No newline at end of file diff --git a/app/assets/stylesheets/nav.scss b/app/assets/stylesheets/nav.scss new file mode 100644 index 0000000..936969c --- /dev/null +++ b/app/assets/stylesheets/nav.scss @@ -0,0 +1,30 @@ +.navbar { + padding: 0.75rem 1rem; + transition: all 0.3s ease; +} + +.navbar-brand { + font-size: 1.25rem; + transition: all 0.3s ease; +} + +.nav-link { + position: relative; + padding: 0.5rem 1rem; +} + +.nav-link:after { + content: ''; + position: absolute; + bottom: 0; + left: 1rem; + right: 1rem; + height: 2px; + background: rgba(255, 255, 255, 0.5); + transform: scaleX(0); + transition: transform 0.3s ease; +} + +.nav-link:hover:after { + transform: scaleX(1); +} diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb new file mode 100644 index 0000000..cd0678d --- /dev/null +++ b/app/controllers/admin/badges_controller.rb @@ -0,0 +1,49 @@ +class Admin::BadgesController < ApplicationController + include BadgesHelper + before_action :set_badge, only: [ :show, :edit, :update, :destroy ] + + def index + @badges = Badge.all + end + + def new + @badge = Badge.new + end + + def create + @badge = Badge.new(badge_params) + if @badge.save + redirect_to admin_badges_path, notice: t("admin.badge.create.success") + else + render :new + end + end + + def update + if @badge.update(badge_params) + redirect_to admin_badges_path, notice: t("admin.badge.update.success") + else + render :edit + end + end + + def edit + @rules = Badge.available_badge_rules + end + + def destroy + @badge.destroy + redirect_to admin_badges_path, notice: t("admin.badge.delete.success") + end + + + private + + def set_badge + @badge = Badge.find(params[:id]) + end + + def badge_params + params.require(:badge).permit(:title, :text, :rule, :image_url) + end +end diff --git a/app/controllers/badges_controller.rb b/app/controllers/badges_controller.rb new file mode 100644 index 0000000..49e75fa --- /dev/null +++ b/app/controllers/badges_controller.rb @@ -0,0 +1,11 @@ +class BadgesController < ApplicationController + include BadgesHelper + + before_action :authenticate_user! + + def index + @all_badges = Badge.all + @my_badge_ids = current_user.badges.pluck(:id) + @badge_counts = current_user.badge_users.group(:badge_id).count + end +end diff --git a/app/controllers/feedbacks_controller.rb b/app/controllers/feedbacks_controller.rb new file mode 100644 index 0000000..630cd32 --- /dev/null +++ b/app/controllers/feedbacks_controller.rb @@ -0,0 +1,25 @@ +class FeedbacksController < ApplicationController + before_action :authenticate_user! + def new + @feedback = Feedback.new + end + + def create + @feedback = Feedback.new(feedback_params) + @feedback.email = current_user.email + @feedback.author = current_user + + if @feedback.valid? + FeedbackMailer.feedback_email(@feedback).deliver_later + redirect_to root_path, notice: t(".success") + else + render :new + end + end + + private + + def feedback_params + params.require(:feedback).permit(:name, :message) + end +end diff --git a/app/controllers/test_passages_controller.rb b/app/controllers/test_passages_controller.rb index 0352c8f..a9b4b46 100644 --- a/app/controllers/test_passages_controller.rb +++ b/app/controllers/test_passages_controller.rb @@ -1,5 +1,4 @@ class TestPassagesController < ApplicationController - before_action :authenticate_user! before_action :set_test_passage, only: %i[ show result update ] @@ -8,7 +7,7 @@ def show; end def result; end def update - if @test_passage.question_any?(params) + if @test_passage.question_any?(params) @test_passage.accept!(params[:answer_ids]) completed_test @@ -25,8 +24,16 @@ def set_test_passage def completed_test if @test_passage.completed? - - TestMailer.completed_test(@test_passage).deliver_now + + TestMailer.completed_test(@test_passage).deliver_later + + if @test_passage.test_successful? + new_badges = BadgeAwardService.new(@test_passage).call + + if new_badges.any? + flash[:notice] = t("test_passages.badge", name_badge: new_badges.map(&:title).join(", ")) + end + end redirect_to result_test_passage_path(@test_passage) else diff --git a/app/helpers/admin/badges_helper.rb b/app/helpers/admin/badges_helper.rb new file mode 100644 index 0000000..999fdf4 --- /dev/null +++ b/app/helpers/admin/badges_helper.rb @@ -0,0 +1,2 @@ +module Admin::BadgesHelper +end diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb new file mode 100644 index 0000000..918bd13 --- /dev/null +++ b/app/helpers/badges_helper.rb @@ -0,0 +1,40 @@ +module BadgesHelper + def available_badge_rules + { + first_attempt: { + description: I18n.t("activerecord.badge.first_attempt") + }, + all_tests_in_category: { + description: I18n.t("activerecord.badge.all_tests_in_category"), + options: Category.pluck(:title, :id) + }, + all_tests_of_level: { + description: I18n.t("activerecord.badge.all_tests_of_level"), + options: (1..5).to_a + } + }.with_indifferent_access + end + + def badge_rule_description(badge) + return badge.rule unless badge.rule.present? + + rule_parts = badge.rule.split(":") + rule_name = rule_parts[0] + param = rule_parts[1] + + rule_data = available_badge_rules[rule_name] + return badge.rule unless rule_data + + base_description = rule_data[:description] + + case rule_name + when "all_tests_in_category" + category = Category.find_by(id: param) + category ? "#{base_description} '#{category.title}'" : base_description + when "all_tests_of_level" + "#{base_description} #{param}" + else + base_description + end + end +end diff --git a/app/helpers/feedbacks_helper.rb b/app/helpers/feedbacks_helper.rb new file mode 100644 index 0000000..b740404 --- /dev/null +++ b/app/helpers/feedbacks_helper.rb @@ -0,0 +1,2 @@ +module FeedbacksHelper +end diff --git a/app/javascript/utilities/progress_bar.js b/app/javascript/utilities/progress_bar.js index e2c684a..721c12a 100644 --- a/app/javascript/utilities/progress_bar.js +++ b/app/javascript/utilities/progress_bar.js @@ -1,5 +1,7 @@ document.addEventListener('turbo:load', function() { - if (document.URL.includes('/test_passages/')) { + const testPassageUrlPattern = /\/test_passages\/\d+$/; + + if (testPassageUrlPattern.test(document.URL)) { const progress_bars = document.querySelector('.progress-bar'); const totalQuestion = progress_bars.dataset.totalQuestion @@ -7,6 +9,6 @@ document.addEventListener('turbo:load', function() { let progress_test = (100 / totalQuestion) * (currentQuestion - 1) progress_bars.style = "width: " + progress_test + "%" - progress_bars.ariaValuenow = progress_test + progress_bars.setAttribute('aria-valuenow', progress_test) } }) diff --git a/app/javascript/utilities/rules_badge.js b/app/javascript/utilities/rules_badge.js new file mode 100644 index 0000000..7dfe0ca --- /dev/null +++ b/app/javascript/utilities/rules_badge.js @@ -0,0 +1,70 @@ +function initBadgeRules() { + const ruleTypeSelect = document.getElementById('rule_type_select'); + if (!ruleTypeSelect) return; + + const paramContainer = document.getElementById('rule_parameter_container'); + const paramSelect = document.getElementById('rule_parameter_select'); + const ruleField = document.getElementById('rule_field'); + + const badgeRulesData = document.getElementById('badge-rules-data'); + const rules = JSON.parse(badgeRulesData.dataset.rules); + const currentRule = badgeRulesData.dataset.currentRule || ''; + + updateRuleField(); + + ruleTypeSelect.addEventListener('change', function() { + const ruleType = this.value; + const ruleData = rules[ruleType]; + + paramContainer.classList.add('d-none'); + paramSelect.innerHTML = ''; + + if (ruleType && ruleData.options) { + ruleData.options.forEach(option => { + const [text, value] = Array.isArray(option) ? option : [option, option]; + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = text; + paramSelect.appendChild(opt); + }); + + paramContainer.classList.remove('d-none'); + } + + updateRuleField(); + }); + + paramSelect.addEventListener('change', updateRuleField); + + function updateRuleField() { + const ruleType = ruleTypeSelect.value; + const param = paramSelect.value; + + if (ruleType) { + ruleField.value = param ? `${ruleType}:${param}` : ruleType; + } else { + ruleField.value = ''; + } + } + + if (currentRule) { + const [ruleType, param] = currentRule.split(':'); + + if (ruleType) { + ruleTypeSelect.value = ruleType; + + const event = new Event('change'); + ruleTypeSelect.dispatchEvent(event); + + setTimeout(() => { + if (param && paramSelect) { + paramSelect.value = param; + updateRuleField(); + } + }, 100); + } + } +} + +document.addEventListener('DOMContentLoaded', initBadgeRules); +document.addEventListener('turbo:load', initBadgeRules); diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index c3f6647..fe462df 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: %{"TestGuru" } - layout 'mailer' + default from: "TestGuru <#{ENV.fetch('SMTP_USERNAME', nil)}>" + layout "mailer" end diff --git a/app/mailers/feedback_mailer.rb b/app/mailers/feedback_mailer.rb new file mode 100644 index 0000000..85050c0 --- /dev/null +++ b/app/mailers/feedback_mailer.rb @@ -0,0 +1,11 @@ +class FeedbackMailer < ApplicationMailer + default from: -> { ENV.fetch("SMTP_USERNAME") || "feedback@testguru.com" } + + def feedback_email(feedback) + @feedback = feedback + mail( + to: ENV.fetch("EMAIL_TO_ADMIN", "admin@testguru.com"), + subject: "New feedback from #{feedback.name}" + ) + end +end diff --git a/app/models/badge.rb b/app/models/badge.rb new file mode 100644 index 0000000..953d788 --- /dev/null +++ b/app/models/badge.rb @@ -0,0 +1,6 @@ +class Badge < ApplicationRecord + has_many :badge_users, dependent: :destroy + has_many :users, through: :badge_users + + validates :title, :text, :rule, :image_url, presence: true +end diff --git a/app/models/badge_user.rb b/app/models/badge_user.rb new file mode 100644 index 0000000..d3917ff --- /dev/null +++ b/app/models/badge_user.rb @@ -0,0 +1,4 @@ +class BadgeUser < ApplicationRecord + belongs_to :user + belongs_to :badge +end diff --git a/app/models/feedback.rb b/app/models/feedback.rb new file mode 100644 index 0000000..acd1afd --- /dev/null +++ b/app/models/feedback.rb @@ -0,0 +1,7 @@ +class Feedback < ApplicationRecord + belongs_to :author, class_name: "User", foreign_key: "user_id" + + validates :name, presence: true, length: { minimum: 2 } + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :message, presence: true +end diff --git a/app/models/test_passage.rb b/app/models/test_passage.rb index 110cef5..31bb45e 100644 --- a/app/models/test_passage.rb +++ b/app/models/test_passage.rb @@ -7,6 +7,10 @@ class TestPassage < ApplicationRecord before_validation :set_current_question + def passed? + completed? && test_successful? + end + def accept!(answer_ids) if correct_answer?(answer_ids) self.correct_question += 1 @@ -16,10 +20,7 @@ def accept!(answer_ids) end def completed? - if current_question.nil? - self.current_question = nil - true - end + current_question.nil? end def current_question_number diff --git a/app/models/user.rb b/app/models/user.rb index 89ad4e6..cdba341 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,9 +14,12 @@ class User < ApplicationRecord has_many :test_passages, dependent: :delete_all has_many :tests, through: :test_passages has_many :created_tests, class_name: "Test", foreign_key: "author_id" + has_many :feedbacks, dependent: :delete_all validates :email, presence: true, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } + has_many :badge_users, dependent: :destroy + has_many :badges, through: :badge_users def tests_by_level(level) tests.by_level(level) diff --git a/app/services/badge_award_service.rb b/app/services/badge_award_service.rb new file mode 100644 index 0000000..492a496 --- /dev/null +++ b/app/services/badge_award_service.rb @@ -0,0 +1,44 @@ +class BadgeAwardService + RULE_CLASSES = { + first_attempt: "Badges::FirstAttempt", + all_tests_in_category: "Badges::AllTestsInCategory", + all_tests_of_level: "Badges::AllTestsOfLevel" + }.with_indifferent_access.freeze + + + def initialize(test_passage) + @test_passage = test_passage + @user = test_passage.user + @test = test_passage.test + @new_badges = [] + end + + def call + badges = Badge.includes(:badge_users).all + + badges.each do |badge| + rule_name, param = badge.rule.split(":") + rule_class = self.class.rule_class_for(rule_name) + + next unless rule_class + + if rule_class.reward?(@user, @test_passage, param) + award_badge(badge) + end + end + + @new_badges + end + + def self.rule_class_for(rule_name) + RULE_CLASSES[rule_name]&.constantize + end + + + private + + def award_badge(badge) + BadgeUser.create!(user: @user, badge: badge) + @new_badges << badge + end +end diff --git a/app/services/badges/all_tests_in_category.rb b/app/services/badges/all_tests_in_category.rb new file mode 100644 index 0000000..065e8d1 --- /dev/null +++ b/app/services/badges/all_tests_in_category.rb @@ -0,0 +1,20 @@ +module Badges + class AllTestsInCategory + def self.reward?(user, test_passage, category_id) + return false unless test_passage.test.category_id == category_id.to_i + + category = Category.find_by(id: category_id) + return false unless category + + category_tests = Test.where(category: category).pluck(:id) + return false if category_tests.empty? + + latest_passed = user.test_passages + .where(test_id: category_tests) + .select(&:passed?) + .map(&:test_id) + + category_tests.map { |num| latest_passed.count(num) }.uniq.size == 1 + end + end +end diff --git a/app/services/badges/all_tests_of_level.rb b/app/services/badges/all_tests_of_level.rb new file mode 100644 index 0000000..406798c --- /dev/null +++ b/app/services/badges/all_tests_of_level.rb @@ -0,0 +1,19 @@ +module Badges + class AllTestsOf1Level + def self.reward?(user, test_passage, target_level) + return false unless test_passage.test.level == target_level.to_i + + tests_of_level = Test.where(level: target_level).pluck(:id) + return false if tests_of_level.empty? + + user_passed_tests = user.test_passages + .joins(:test) + .where(tests: { level: target_level }) + .select(&:passed?) + .map(&:test) + .pluck(:id) + + tests_of_level.map { |num| user_passed_tests.count(num) }.uniq.size == 1 + end + end +end diff --git a/app/services/badges/first_attempt.rb b/app/services/badges/first_attempt.rb new file mode 100644 index 0000000..85807e0 --- /dev/null +++ b/app/services/badges/first_attempt.rb @@ -0,0 +1,8 @@ +module Badges + class FirstAttempt + def self.reward?(user, test_passage, _param = nil) + test_passage.passed? && + user.test_passages.where(test: test_passage.test).passed?.count == 1 + end + end +end diff --git a/app/views/admin/_nav.html.erb b/app/views/admin/_nav.html.erb index b9a86ed..cddadc0 100644 --- a/app/views/admin/_nav.html.erb +++ b/app/views/admin/_nav.html.erb @@ -1,13 +1,61 @@ -