From 25e6254e6248ba362468b5cbd386a1bc4f96167d Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Wed, 3 Jun 2026 16:52:11 +0200 Subject: [PATCH] na --- Gemfile.lock | 4 +- app/assets/images/ico-water.svg | 1 + app/assets/stylesheets/application.css | 118 ++++++++++++++++++ app/controllers/game_controller.rb | 90 +++++++++++++ app/controllers/site_controller.rb | 41 ------ .../{site_helper.rb => game_helper.rb} | 2 +- app/javascript/application.js | 2 + app/javascript/chance_controller.js | 26 ++++ app/models/player.rb | 48 +++---- .../{site => game}/_language-menu.html.erb | 2 +- app/views/{site => game}/facts.html.erb | 0 app/views/{site => game}/intro.html.erb | 0 app/views/game/stage.html.erb | 53 ++++++++ .../stage_result.html.erb} | 21 ++-- app/views/{site => game}/start.html.erb | 3 +- app/views/layouts/application.html.erb | 2 +- config/importmap.rb | 1 + config/locales/en.yml | 14 ++- config/routes.rb | 13 +- db/migrate/20260603141335_create_players.rb | 11 ++ db/schema.rb | 10 +- 21 files changed, 366 insertions(+), 96 deletions(-) create mode 100644 app/assets/images/ico-water.svg create mode 100644 app/controllers/game_controller.rb delete mode 100644 app/controllers/site_controller.rb rename app/helpers/{site_helper.rb => game_helper.rb} (98%) create mode 100644 app/javascript/chance_controller.js rename app/views/{site => game}/_language-menu.html.erb (96%) rename app/views/{site => game}/facts.html.erb (100%) rename app/views/{site => game}/intro.html.erb (100%) create mode 100644 app/views/game/stage.html.erb rename app/views/{site/stage.html.erb => game/stage_result.html.erb} (54%) rename app/views/{site => game}/start.html.erb (86%) create mode 100644 db/migrate/20260603141335_create_players.rb diff --git a/Gemfile.lock b/Gemfile.lock index 85b739e..519c9e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,7 +138,7 @@ GEM jbuilder (2.15.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.19.7) + json (2.19.8) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -456,7 +456,7 @@ CHECKSUMS io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 - json (2.19.7) sha256=fe432c8639f6efff69f9d73b518a3705d9581ab93156f981ea72806e1e5bcc3e + json (2.19.8) sha256=6354310fd76ef69b87d5bd1f38b40d730613baf90b6803d2d0a48f618d32dfaa kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 diff --git a/app/assets/images/ico-water.svg b/app/assets/images/ico-water.svg new file mode 100644 index 0000000..aea6d06 --- /dev/null +++ b/app/assets/images/ico-water.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index ed6d139..4d327fb 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,3 +1,9 @@ +@property --glow-deg { + syntax: ""; + inherits: true; + initial-value: 0deg; +} + @font-face { font-display: swap; font-family: 'Noto IKEA'; @@ -128,6 +134,10 @@ ul[class] { list-style: none; } +form { + margin: 0; +} + header { background-color: var(--clr-white); position: fixed; @@ -326,14 +336,20 @@ main { } } +form:has(.cta) { + display: flex; +} + .cta { display: flex; + flex: 1; align-items: center; justify-content: center; background-color: var(--clr-action, var(--clr-sand-light)); background-image: url("ico-arrow-right.svg"); background-repeat: no-repeat; background-position: 1rem 50%; + border: none; max-width: 17.5rem; border-radius: 100vw; height: 3rem; @@ -446,4 +462,106 @@ main { font: var(--td-s); text-transform: uppercase; font-weight: 700; +} + +@keyframes glow { + from { + --glow-deg: 0deg; + } + to { + --glow-deg: 360deg; + } +} + +.answers-container { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.5rem; + + & form { + display: flex; + } + + & button { + text-align: left; + appearance: none; + border: 2px solid transparent; + flex-grow: 1; + line-height: 1.25; + background: var(--clr-white); + border-radius: 100vw; + font-weight: 700; + padding: 1rem; + box-sizing: border-box; + + &.chance { + animation: glow 10s linear infinite; + background: linear-gradient(var(--clr-white), var(--clr-white)) padding-box, + linear-gradient(var(--glow-deg), var(--clr-blue), var(--clr-green), #74B2FF, var(--clr-green)) border-box; + } + } +} + +dialog { + border: none; + border-radius: 0.5rem; + width: 280px; + max-width: 90%; + padding: 0; + + & > * { + padding: 0 1.25rem; + } + + & > *:first-child { + padding-block-start: 2rem; + } + & > *:last-child { + padding-block-end: 2rem; + } + + & h2 { + padding-block-end: 2rem; + margin: 0; + } + + & svg { + fill: var(--clr-white); + rotate: 180deg; + } +} + +dialog::backdrop { + background: rgba(0,0,0,.5); +} + +.header-label { + text-transform: uppercase; + font: var(--td-s); + font-weight: 700; + padding-block-end: 1rem; +} + +.dialog-actions { + background-color: var(--clr-green); + display: flex; + flex-direction: column; + gap: 2rem; + + & svg { + align-self: stretch; + width: 100%; + height: auto; + } + + & > div { + display: flex; + gap: 0.5rem; + } + + & button { + text-align: center; + flex: 1; + } } \ No newline at end of file diff --git a/app/controllers/game_controller.rb b/app/controllers/game_controller.rb new file mode 100644 index 0000000..3082d13 --- /dev/null +++ b/app/controllers/game_controller.rb @@ -0,0 +1,90 @@ +class GameController < ApplicationController + include QuizHelperMethods + skip_before_action :require_player!, only: [ :index, :start ] + + before_action :set_node, only: [ :stage, :answer, :stage_result ] + + helper_method :root_node, + :stage_index, + :n_stages + + def index + @node = root_node + not_found and return unless @node + + render action: @node.template + end + + # POST + def start + player = Player.create(locale: I18n.locale.to_s) + session[:player_id] = player.id + redirect_to action: "facts" + end + + + def facts + @node = root_node&.children&.facts&.first + end + + + def intro + @node = root_node&.children&.intro&.first + end + + + def stage + end + + + # POST + def answer + @answer = @node.children.find_by(id: params[:value]) + current_player.record_answer(stage_index, @answer.id) + + redirect_to action: :stage_result, id: params[:id] + end + + + def stage_result + + end + + +private + + def root_node + @root_node ||= Node.roots.viewable.first + end + + + def stages + @stages ||= root_node.children.stage + end + + + def set_node + @node = stages[params[:id].to_i - 1] + not_found and return unless @node + end + + + + def stage_index + @stage_index ||= stages.index(@node) + 1 + end + + + def n_stages + @n_stages ||= stages.size + end + + + + def url_from_param + return "" unless params[:url] + + # return File.join('', params[:url]) if I18n.default_locale == I18n.locale + File.join("", I18n.locale.to_s, params[:url]) + end +end diff --git a/app/controllers/site_controller.rb b/app/controllers/site_controller.rb deleted file mode 100644 index bf2e03e..0000000 --- a/app/controllers/site_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -class SiteController < ApplicationController - before_action :set_locale - - helper_method :root_node - - def index - @node = root_node - not_found and return unless @node - - render(template: "site/#{@node.template}") - end - - - def facts - @node = root_node&.children&.facts&.first - end - - - def intro - @node = root_node&.children&.intro&.first - end - - - def stage - @node = root_node&.children&.stage&.first - end - - -private - - def root_node - @root_node ||= Node.roots.viewable.first - end - - def url_from_param - return "" unless params[:url] - - # return File.join('', params[:url]) if I18n.default_locale == I18n.locale - File.join("", I18n.locale.to_s, params[:url]) - end -end diff --git a/app/helpers/site_helper.rb b/app/helpers/game_helper.rb similarity index 98% rename from app/helpers/site_helper.rb rename to app/helpers/game_helper.rb index ca900d0..f602bc2 100644 --- a/app/helpers/site_helper.rb +++ b/app/helpers/game_helper.rb @@ -1,4 +1,4 @@ -module SiteHelper +module GameHelper def frontend_javascript_importmap_tags(only_use = %w[application]) only_use = Array(only_use) importmap_json = JSON.parse(Rails.application.importmap.to_json(resolver: self))["imports"].select { |k, v| only_use.include?(k) } diff --git a/app/javascript/application.js b/app/javascript/application.js index 9057320..3accfff 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -4,12 +4,14 @@ import { Application } from "@hotwired/stimulus" import LanguageMenuController from "language_menu_controller" import CarouselController from "carousel_controller" import IntroController from "intro_controller" +import ChanceController from "chance_controller" const application = Application.start() application.register("language-menu", LanguageMenuController) application.register("carousel", CarouselController) application.register("intro", IntroController) +application.register("chance", ChanceController) // Configure Stimulus development experience application.debug = false diff --git a/app/javascript/chance_controller.js b/app/javascript/chance_controller.js new file mode 100644 index 0000000..8bccc3e --- /dev/null +++ b/app/javascript/chance_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["dialog"] + + confirm(event) { + event.preventDefault() + this.pendingForm = event.target + this.dialogTarget.showModal() + } + + ok() { + this.dialogTarget.close() + this.pendingForm?.submit() + } + + cancel() { + this.dialogTarget.close() + this.pendingForm = null + } + + backdropClick(event) { + console.info(event.target === this.dialogTarget) + if (event.target === this.dialogTarget) this.cancel() + } +} diff --git a/app/models/player.rb b/app/models/player.rb index 4e5edf1..af7eefa 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -1,45 +1,29 @@ class Player < ApplicationRecord - attribute :current_stage, :integer, default: 1 - attribute :bonus_points, :integer, default: 0 - attribute :progress, :json, default: {} - attribute :is_done, :boolean, default: false + # progress shape: + # { "1" => { "answer_id" => 42, "result_id" => 99 }, "2" => { ... } } - scope :playing, -> { where(is_done: false) } - scope :done, -> { where(is_done: true) } - - - def is_playing? - !self.is_done? + def record_answer(stage, answer_id) + stage_data(stage)["answer_id"] = answer_id + save end - - def reset_when_game_over - self.progress = {} - self.current_stage = 1 - self.save + def record_result(stage, result_id) + stage_data(stage)["result_id"] = result_id + save end - # stage_index: 1, 2, 3... - # outcome: 'chance' or 'choice' - def record_flip(stage_index, outcome) - self.progress[stage_index.to_s] ||= {} - self.progress[stage_index.to_s]["flip"] = outcome - save + def answer_id_for(stage) + progress[stage.to_s]&.dig("answer_id") end + def result_id_for(stage) + progress[stage.to_s]&.dig("result_id") + end - # stage_index: 1, 2, 3... - # result: 'good', 'bad', 'game_over' - def record_pick_result(stage_index, result) - self.progress[stage_index.to_s] ||= {} - self.progress[stage_index.to_s]["result"] = result - save - end +private - def record_bonus_pick_result(stage_index, result) - self.progress[stage_index.to_s] ||= {} - self.progress[stage_index.to_s]["bonus_result"] = result - save + def stage_data(stage) + progress[stage.to_s] ||= {} end end diff --git a/app/views/site/_language-menu.html.erb b/app/views/game/_language-menu.html.erb similarity index 96% rename from app/views/site/_language-menu.html.erb rename to app/views/game/_language-menu.html.erb index 3ab8e62..00f526c 100644 --- a/app/views/site/_language-menu.html.erb +++ b/app/views/game/_language-menu.html.erb @@ -1,6 +1,6 @@
- <%= link_to svg("ikea-foundation-203x22"), url_for(controller: "site", action: "index") %> + <%= link_to svg("ikea-foundation-203x22"), url_for(controller: "game", action: "index") %> + +
+ + + + <% end %> + + diff --git a/app/views/site/stage.html.erb b/app/views/game/stage_result.html.erb similarity index 54% rename from app/views/site/stage.html.erb rename to app/views/game/stage_result.html.erb index f302abb..867948e 100644 --- a/app/views/site/stage.html.erb +++ b/app/views/game/stage_result.html.erb @@ -3,18 +3,16 @@
-
    -
  1. -
  2. -
  3. -
  4. -
  5. +
      + <% n_stages.times do |i| %> + <%= tag.li class: ("done" if stage_index >= (i + 1)) %> + <% end %>
    -
    -
    <%= svg "ico-soil" %>
    +
    +
    <%= svg t("icons.stages")[stage_index] %>
    - <%= tag.div t("game.stage_i_of_n", i: 1, n: 5), class: "stage-progress" %> + <%= tag.div t("game.stage_i_of_n", i: stage_index, n: n_stages), class: "stage-progress" %> <%= tag.h1 @node.title %>
    @@ -28,6 +26,7 @@ <%= svg "ico-wave" %>
    -
    - <%= tag.h1 t("game.intro_title").html_safe %> +
    + +
    diff --git a/app/views/site/start.html.erb b/app/views/game/start.html.erb similarity index 86% rename from app/views/site/start.html.erb rename to app/views/game/start.html.erb index de3e647..604595f 100644 --- a/app/views/site/start.html.erb +++ b/app/views/game/start.html.erb @@ -13,7 +13,8 @@ <%= tag.h1 t("game.intro_title").html_safe %> <%= tag.p t("game.intro_description") %> - <%= link_to tag.span(t("game.let_me_try")), {action: "facts"}, class: "cta" %> + <%= button_to tag.span(t("game.let_me_try")), start_path, class: "cta" %> + <%= tag.div class: "play-time" do %> <%= svg "ico-clock" %> <%= tag.span t("game.play_time") %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index c503512..4fb0276 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,7 +17,7 @@ <%= stylesheet_link_tag "reset", "application" %> - <%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller] %> + <%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller] %> <%= content_for :header %> diff --git a/config/importmap.rb b/config/importmap.rb index 416b4cb..dda8381 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -17,3 +17,4 @@ pin "application", preload: false pin "language_menu_controller", preload: false pin "carousel_controller", preload: false pin "intro_controller", preload: false +pin "chance_controller", preload: false diff --git a/config/locales/en.yml b/config/locales/en.yml index 1c06079..6586d5c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -18,6 +18,13 @@ en: got_it_lets_get_started: Got it, let’s get started stage_i_of_n: Stage %{i} of %{n} + take_a_chance: Take a chance + its_risky: | + It’s risky
    + but it can boost your score. + ok: Ok + cancel: Cancel + countries: au: Australia at: Austria @@ -326,7 +333,12 @@ en: tags: sell date_formats: schedule subscribers: group - newsletters: mail + newsletters: mail + + stages: + - + - ico-soil + - ico-water sessions: login: Log in diff --git a/config/routes.rb b/config/routes.rb index 865a20e..332f71c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,12 +60,17 @@ Rails.application.routes.draw do scope ":locale", constraints: { locale: /en|zh|hr|cs|da|nl|fi|fr|fr-CA|de|hu|it|ja|ko|nb|pl|pt|ro|sr|sk|sl|es|sv|uk/ } do # get "*url", to: "site#page", constraints: lambda { |req| req.path.exclude?("storage") } - get "facts", to: "site#facts" - get "intro", to: "site#intro" + post "start", to: "game#start", as: :start + get "facts", to: "game#facts" + get "intro", to: "game#intro" - get "stage/:id", to: "site#stage" + get "stage/:id", to: "game#stage" - get "", to: "site#index", as: :locale_root + post "stage/:id/answer", to: "game#answer" + + get "stage/:id/result", to: "game#stage_result" + + get "", to: "game#index", as: :locale_root end # Defines the root path route ("/") diff --git a/db/migrate/20260603141335_create_players.rb b/db/migrate/20260603141335_create_players.rb new file mode 100644 index 0000000..9cb5d2a --- /dev/null +++ b/db/migrate/20260603141335_create_players.rb @@ -0,0 +1,11 @@ +class CreatePlayers < ActiveRecord::Migration[8.1] + def change + create_table :players do |t| + t.string :locale, null: false + t.integer :score, default: 0, null: false + t.jsonb :progress, default: {}, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 34cdb3b..f727eeb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_04_27_131737) do +ActiveRecord::Schema[8.1].define(version: 2026_06_03_141335) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -114,6 +114,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_27_131737) do t.index ["url"], name: "index_nodes_on_url", using: :gin end + create_table "players", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "locale", null: false + t.jsonb "progress", default: {}, null: false + t.integer "score", default: 0, null: false + t.datetime "updated_at", null: false + end + create_table "quiz_results", force: :cascade do |t| t.datetime "created_at", null: false t.string "locale", null: false