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..905d720 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,4 +1,5 @@ -@import "bootstrap"; - +@import "../node_modules/bootstrap/scss/bootstrap"; @import "typography/icons"; @import "global"; +@import "badge/style"; +@import "badge/nav"; 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..b641588 --- /dev/null +++ b/app/controllers/admin/badges_controller.rb @@ -0,0 +1,53 @@ +class Admin::BadgesController < ApplicationController + before_action :set_badge, only: %i[show edit update destroy] + + def index + @badges = Badge.all + end + + def show; end + + def new + @badge = Badge.new + @rules = Badge.rules_with_descriptions + end + + def edit + @rules = Badge.rules_with_descriptions + end + + def create + @badge = Badge.new(badge_params) + + if @badge.save + redirect_to admin_badges_path, notice: t("admin.badge.create.success") + else + @rules = Badge.rules_with_descriptions + render :new + end + end + + def update + if @badge.update(badge_params) + redirect_to admin_badges_path, notice: t("admin.badge.update.success") + else + @rules = Badge.rules_with_descriptions + render :edit + end + 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..9cb3c6c --- /dev/null +++ b/app/controllers/badges_controller.rb @@ -0,0 +1,8 @@ +class BadgesController < ApplicationController + before_action :authenticate_user! + + def index + @all_badges = Badge.all + @my_badge_ids = current_user.badges.pluck(:id) + end +end diff --git a/app/controllers/feedbacks_controller.rb b/app/controllers/feedbacks_controller.rb new file mode 100644 index 0000000..9daf772 --- /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.save + FeedbackMailer.feedback_email(@feedback).deliver_now + 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..2db52a4 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,9 +24,15 @@ def set_test_passage def completed_test if @test_passage.completed? - + TestMailer.completed_test(@test_passage).deliver_now + 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 + redirect_to result_test_passage_path(@test_passage) else redirect_to test_passage_path(@test_passage) 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..a42504e --- /dev/null +++ b/app/helpers/badges_helper.rb @@ -0,0 +1,2 @@ +module BadgesHelper +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/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..e68314d --- /dev/null +++ b/app/models/badge.rb @@ -0,0 +1,19 @@ +class Badge < ApplicationRecord + has_many :badge_users, dependent: :destroy + has_many :users, through: :badge_users + + validates :title, :text, :rule, :image_url, presence: true + + def self.available_rules + BadgeAwardService::RULE_CLASSES.keys.map(&:to_s) + end + + def self.rules_with_descriptions + { + all_backend_tests: I18n.t("activerecord.badge.all_backend_tests"), + first_attempt: I18n.t("activerecord.badge.first_attempt"), + all_frontend_tests: I18n.t("activerecord.badge.all_frontend_tests"), + all_tests_of_1_level: I18n.t("activerecord.badge.all_tests_of_1_level") + } + end +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..a9852db --- /dev/null +++ b/app/models/feedback.rb @@ -0,0 +1,7 @@ +class Feedback < ApplicationRecord + validates :name, presence: true, length: { minimum: 2 } + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :message, presence: true + + belongs_to :author, class_name: "User", foreign_key: "user_id" +end diff --git a/app/models/test_passage.rb b/app/models/test_passage.rb index 110cef5..680bacd 100644 --- a/app/models/test_passage.rb +++ b/app/models/test_passage.rb @@ -7,6 +7,11 @@ class TestPassage < ApplicationRecord before_validation :set_current_question + def passed + completed? && test_successful? + end + alias_method :passed?, :passed + def accept!(answer_ids) if correct_answer?(answer_ids) self.correct_question += 1 @@ -16,10 +21,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..71d65ed --- /dev/null +++ b/app/services/badge_award_service.rb @@ -0,0 +1,39 @@ +class BadgeAwardService + RULE_CLASSES = { + all_backend_tests: "Badges::AllBackendTests", + first_attempt: "Badges::FirstAttempt", + all_frontend_tests: "Badges::AllFrontendTests", + all_tests_of_1_level: "Badges::AllTestsOf1Level" + }.freeze + + def initialize(test_passage) + @test_passage = test_passage + @user = test_passage.user + @new_badges = [] + end + + def call + Badge.where.not(id: @user.badge_ids).each do |badge| + rule_class = self.class.rule_class_for(badge.rule) + next unless rule_class + + if rule_class.reward?(@user, @test_passage) + award_badge(badge) + end + end + @new_badges + end + + def self.rule_class_for(rule_name) + RULE_CLASSES[rule_name.to_sym]&.constantize + end + + private + + def award_badge(badge) + unless @user.badges.include?(badge) + @user.badges << badge + @new_badges << badge + end + end +end diff --git a/app/services/badges/all_backend_tests.rb b/app/services/badges/all_backend_tests.rb new file mode 100644 index 0000000..f787ca7 --- /dev/null +++ b/app/services/badges/all_backend_tests.rb @@ -0,0 +1,18 @@ +module Badges + class AllBackendTests + def self.reward?(user, test_passage = nil) + category = Category.find_by(title: "Backend") + return false unless category + + tests_in_category = Test.where(category: category) + return false if tests_in_category.empty? + + user.test_passages.joins(:test) + .where(tests: { category: category }) + .select(&:passed?) + .map(&:test) + .uniq + .count == tests_in_category.count + end + end +end diff --git a/app/services/badges/all_frontend_tests.rb b/app/services/badges/all_frontend_tests.rb new file mode 100644 index 0000000..33a9b79 --- /dev/null +++ b/app/services/badges/all_frontend_tests.rb @@ -0,0 +1,18 @@ +module Badges + class AllFrontendTests + def self.reward?(user, test_passage = nil) + category = Category.find_by(title: "Frontend") + return false unless category + + tests_in_category = Test.where(category: category) + return false if tests_in_category.empty? + + user.test_passages.joins(:test) + .where(tests: { category: category }) + .select(&:passed?) + .map(&:test) + .uniq + .count == tests_in_category.count + end + end +end diff --git a/app/services/badges/all_tests_of_1_level.rb b/app/services/badges/all_tests_of_1_level.rb new file mode 100644 index 0000000..1f72213 --- /dev/null +++ b/app/services/badges/all_tests_of_1_level.rb @@ -0,0 +1,19 @@ +module Badges + class AllTestsOf1Level + def self.reward?(user, test_passage = nil) + target_level = 1 + + tests_of_level = Test.where(level: target_level) + return false if tests_of_level.empty? + + user_passed_tests = user.test_passages + .joins(:test) + .where(tests: { level: target_level }) + .select(&:passed?) + .map(&:test) + .uniq + + user_passed_tests.size == tests_of_level.count + 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..3ca378f --- /dev/null +++ b/app/services/badges/first_attempt.rb @@ -0,0 +1,8 @@ +module Badges + class FirstAttempt + def self.reward?(user, test_passage) + test_passage.passed? && + TestPassage.where(user: user, test: test_passage.test).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 @@ -