Mattias Bodlund 2 weeks ago
parent
commit
25e6254e62
21 changed files with 366 additions and 96 deletions
  1. +2
    -2
      Gemfile.lock
  2. +1
    -0
      app/assets/images/ico-water.svg
  3. +118
    -0
      app/assets/stylesheets/application.css
  4. +90
    -0
      app/controllers/game_controller.rb
  5. +0
    -41
      app/controllers/site_controller.rb
  6. +1
    -1
      app/helpers/game_helper.rb
  7. +2
    -0
      app/javascript/application.js
  8. +26
    -0
      app/javascript/chance_controller.js
  9. +16
    -32
      app/models/player.rb
  10. +1
    -1
      app/views/game/_language-menu.html.erb
  11. +0
    -0
      app/views/game/facts.html.erb
  12. +0
    -0
      app/views/game/intro.html.erb
  13. +53
    -0
      app/views/game/stage.html.erb
  14. +10
    -11
      app/views/game/stage_result.html.erb
  15. +2
    -1
      app/views/game/start.html.erb
  16. +1
    -1
      app/views/layouts/application.html.erb
  17. +1
    -0
      config/importmap.rb
  18. +13
    -1
      config/locales/en.yml
  19. +9
    -4
      config/routes.rb
  20. +11
    -0
      db/migrate/20260603141335_create_players.rb
  21. +9
    -1
      db/schema.rb

+ 2
- 2
Gemfile.lock View File

@ -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


+ 1
- 0
app/assets/images/ico-water.svg View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="21" height="22" viewBox="0 0 21 22"><path d="M17.62,21.75c-2.03,0-3.25-1.65-3.25-3.25,0-2.4,2.75-4.08,2.86-4.15l.39-.23.39.23c.12.07,2.86,1.75,2.86,4.15,0,1.6-1.22,3.25-3.25,3.25ZM17.62,15.91c-.7.52-1.75,1.54-1.75,2.6,0,.86.65,1.75,1.75,1.75s1.75-.89,1.75-1.75c0-1.06-1.05-2.07-1.75-2.6ZM20.12,11.75v-1.5c-4.28,0-8.14,2.53-9.86,6.45l1.38.6c1.47-3.37,4.8-5.55,8.48-5.55ZM20.12,7.75v-1.5c-6.48,0-12.29,4.33-14.14,10.54l1.44.43c1.66-5.57,6.88-9.46,12.7-9.46ZM20.12,3.75v-1.5C11.31,2.25,3.79,8.25,1.84,16.83l1.46.33C5.09,9.27,12.01,3.75,20.12,3.75ZM2.86,8.6c-.89-.23-1.81-.35-2.73-.35v1.5c.8,0,1.59.1,2.35.3l.38-1.45ZM5.68,5.33c-1.77-.72-3.64-1.08-5.56-1.08v1.5c1.73,0,3.41.33,4.99.97l.57-1.39ZM9.24,2.61C6.47,1.07,3.32.25.12.25v1.5c2.94,0,5.83.75,8.38,2.17l.73-1.31Z"/></svg>

+ 118
- 0
app/assets/stylesheets/application.css View File

@ -1,3 +1,9 @@
@property --glow-deg {
syntax: "<angle>";
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;
}
}

+ 90
- 0
app/controllers/game_controller.rb View File

@ -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

+ 0
- 41
app/controllers/site_controller.rb View File

@ -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

app/helpers/site_helper.rb → app/helpers/game_helper.rb View File

@ -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) }

+ 2
- 0
app/javascript/application.js View File

@ -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


+ 26
- 0
app/javascript/chance_controller.js View File

@ -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()
}
}

+ 16
- 32
app/models/player.rb View File

@ -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

app/views/site/_language-menu.html.erb → app/views/game/_language-menu.html.erb View File

@ -1,6 +1,6 @@
<div data-controller="language-menu">
<header>
<%= link_to svg("ikea-foundation-203x22"), url_for(controller: "site", action: "index") %>
<%= link_to svg("ikea-foundation-203x22"), url_for(controller: "game", action: "index") %>
<button type="button"
class="language-button"

app/views/site/facts.html.erb → app/views/game/facts.html.erb View File


app/views/site/intro.html.erb → app/views/game/intro.html.erb View File


+ 53
- 0
app/views/game/stage.html.erb View File

@ -0,0 +1,53 @@
<%- content_for :title, node_title(@node) %>
<% image_attachment = @node.attachments.select { |a| a.asset&.file&.image? }[0] %>
<div class="stage-header-container">
<ol class="stage-progress" role="progressbar">
<% n_stages.times do |i| %>
<%= tag.li class: ("done" if stage_index >= (i + 1)) %>
<% end %>
</ol>
<div class="stage-header">
<div class="stage-icon"><%= svg t("icons.stages")[stage_index] %></div>
<div>
<%= tag.div t("game.stage_i_of_n", i: stage_index, n: n_stages), class: "stage-progress" %>
<%= tag.h1 @node.title %>
</div>
</div>
</div>
<div class="hero-container">
<%= render_responsive_picture(image_attachment.asset, alt: "Hero", fetchpriority: "high") %>
<article>
<%= image_attachment.body.html_safe %>
</article>
<%= svg "ico-wave" %>
</div>
<article class="answers-container" data-controller="chance">
<% @node.children.each do |node| %>
<%= button_to node.title, url_for(action: "answer", value: node.id),
class: ("chance" if node.chance?),
form: ((stage_index == 1 and node.chance?) ? { data: { action: "submit->chance#confirm" } } : {}) %>
<% end %>
<% if stage_index == 1 %>
<dialog data-chance-target="dialog" data-action="click->chance#backdropClick">
<div class="header-label"><%= t("game.take_a_chance") %></div>
<%= tag.h2 t("game.its_risky").html_safe %>
<div class="dialog-actions">
<%= svg "ico-wave" %>
<div>
<button type="button" data-action="click->chance#cancel"><%= t("game.cancel") %></button>
<button type="button" data-action="click->chance#ok"><%= t("game.ok") %></button>
</div>
</div>
</dialog>
<% end %>
</article>

app/views/site/stage.html.erb → app/views/game/stage_result.html.erb View File

@ -3,18 +3,16 @@
<div class="stage-header-container">
<ol class="stage-progress" role="progressbar" >
<li class="done"></li>
<li></li>
<li></li>
<li></li>
<li></li>
<ol class="stage-progress" role="progressbar">
<% n_stages.times do |i| %>
<%= tag.li class: ("done" if stage_index >= (i + 1)) %>
<% end %>
</ol>
<div class="stage-header">
<div class="stage-icon"><%= svg "ico-soil" %></div>
<div class="stage-header">
<div class="stage-icon"><%= svg t("icons.stages")[stage_index] %></div>
<div>
<%= 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 %>
</div>
</div>
@ -28,6 +26,7 @@
<%= svg "ico-wave" %>
</div>
<article>
<%= tag.h1 t("game.intro_title").html_safe %>
<article class="answers-container">
</article>

app/views/site/start.html.erb → app/views/game/start.html.erb View File

@ -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") %>

+ 1
- 1
app/views/layouts/application.html.erb View File

@ -17,7 +17,7 @@
<link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png">
<%= 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] %>
</head>
<body>
<%= content_for :header %>


+ 1
- 0
config/importmap.rb View File

@ -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

+ 13
- 1
config/locales/en.yml View File

@ -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: |
<strong>It’s risky</strong><br>
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


+ 9
- 4
config/routes.rb View File

@ -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 ("/")


+ 11
- 0
db/migrate/20260603141335_create_players.rb View File

@ -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

+ 9
- 1
db/schema.rb View File

@ -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


Loading…
Cancel
Save