studio-engine 0.86.0 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +4 -1
- data/app/controllers/studio/admin_surveys_controller.rb +94 -0
- data/app/controllers/studio/surveys_controller.rb +173 -0
- data/app/helpers/studio/site_footer_helper.rb +9 -6
- data/app/models/studio/survey_response.rb +150 -0
- data/app/views/studio/admin_surveys/index.html.erb +41 -0
- data/app/views/studio/admin_surveys/show.html.erb +99 -0
- data/app/views/studio/surveys/_question.html.erb +77 -0
- data/app/views/studio/surveys/_script.html.erb +288 -0
- data/app/views/studio/surveys/_styles.html.erb +95 -0
- data/app/views/studio/surveys/not_found.html.erb +11 -0
- data/app/views/studio/surveys/show.html.erb +60 -0
- data/app/views/studio/surveys/sign_in_required.html.erb +15 -0
- data/app/views/studio/surveys/thanks.html.erb +16 -0
- data/app/views/style/_site_footer.html.erb +4 -2
- data/db/migrate/20261005120000_create_studio_survey_responses.rb +47 -0
- data/docs/SITE_FOOTER.md +91 -12
- data/lib/studio/engine.rb +9 -0
- data/lib/studio/site_footer.rb +71 -8
- data/lib/studio/survey/breakdown.rb +96 -0
- data/lib/studio/survey/export.rb +65 -0
- data/lib/studio/survey.rb +303 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +120 -10
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e16f65ea44055886fea1ffb0743a9415caa612f858caa0ff7dabb7c87beeeb15
|
|
4
|
+
data.tar.gz: 9690ad40a9e766646995ddfa1c69c8c22191e3509bb944d5c184ea5c1a1b21e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae4786abebc9f1d9dc86cc3992f4a6f4225c4fceaa20b73fca12ddd43afad1e4d6ef2807a8d3302bdf2d811792995fdac42961a02c6b70637f8923e841e61402
|
|
7
|
+
data.tar.gz: 3ba6c43faf9e1f7a03596b70924fc099469d8c95ce967f63e4a2ba8f4f0da0112db6068e701e739fa7e3ce5435bd07ee456b2982fec91ebb9561a4bc3dd18b09
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,71 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.88.0 — 2026-10-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Surveys.** Define a survey in app code — `Studio.define_survey "first-game"
|
|
12
|
+
do ... end` in `config/surveys/*.rb`, loaded on boot and each reload — with six
|
|
13
|
+
question types (`emoji_scale`, `rating`, `choice`, `multi_choice`,
|
|
14
|
+
`short_text`, `long_text`), each with `required:` and `help:`. The engine
|
|
15
|
+
serves it at `GET /surveys/:slug`: one question per screen with a progress
|
|
16
|
+
bar, tap-to-advance, number keys and Enter, autosave of every answer, resume
|
|
17
|
+
where the respondent left off, a thank-you screen with the survey's
|
|
18
|
+
`next_action`, `prefers-reduced-motion` support, and a single-form fallback
|
|
19
|
+
without JavaScript. Answers are stored in the new `studio_survey_responses`
|
|
20
|
+
table (`Studio::SurveyResponse`) with a snapshot of each question's label and
|
|
21
|
+
the chosen option's label, so editing a survey never rewrites old responses.
|
|
22
|
+
**Opt in** with `config.draw_survey_routes = true`; **run
|
|
23
|
+
`bin/rails studio_engine:install:migrations && bin/rails db:migrate`** first.
|
|
24
|
+
- **`config.survey_ref_resolver`** (`->(controller) { ref }`) attributes an email
|
|
25
|
+
arrival, and **`config.on_survey_completed`** (`->(response) { ... }`) runs once
|
|
26
|
+
when a response completes; a raising hook is logged, never shown. A survey
|
|
27
|
+
answers to a signed-in user, an attributed email arrival, or anyone when it
|
|
28
|
+
declares `allow_anonymous true`.
|
|
29
|
+
- **The survey results panel at `/admin/surveys`**, admin-only: started,
|
|
30
|
+
completed and completion rate per survey; per-question distribution bars and
|
|
31
|
+
averages, a scrolling list of text answers with the respondent's username or
|
|
32
|
+
"anonymous"; date-range and completed-only filters; CSV export. **Opt in**
|
|
33
|
+
with `config.draw_admin_survey_routes = true`. See `docs/SURVEYS.md`.
|
|
34
|
+
|
|
35
|
+
## 0.87.0 — 2026-10-04
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **The site footer is on by default. An unset `config.site_footer` now renders
|
|
40
|
+
a footer wherever `studio_site_footer` is called**; before, it rendered
|
|
41
|
+
nothing. The default footer is built from what the engine already knows: the
|
|
42
|
+
app's name (the site identity's title, else `config.app_name`), its footer or
|
|
43
|
+
navbar logo, the © line, and a Privacy Policy and a Terms of Service link for
|
|
44
|
+
each of the routes named `privacy` and `terms` that the host has (a GET route
|
|
45
|
+
that needs no arguments; anything else is not linked). It has no address, no
|
|
46
|
+
map, no phone, no email, no tagline, no social row and no link columns, and
|
|
47
|
+
names no Leaflet. The rule for where it shows is unchanged. **An app that
|
|
48
|
+
sets `config.site_footer` to a Hash or a callable renders exactly what it did**,
|
|
49
|
+
and an app that does not call the helper is unaffected. A callable that
|
|
50
|
+
answers `nil` still declines the footer for that request.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`config.site_footer = false` turns the footer off.** It is the one opt-out,
|
|
55
|
+
and it is distinct from unset (`nil`), which is the default footer.
|
|
56
|
+
`Studio.site_footer_enabled?` and `Studio.site_footer_declared?` read the
|
|
57
|
+
three states.
|
|
58
|
+
- **`config.site_footer_address`: the location, as one setting.** A Hash of the
|
|
59
|
+
facts' `address:` keys (`street:`, `city_line:`, `lat:`, `lng:`, and the
|
|
60
|
+
optional `zoom:` and `directions_url:`) gives the footer its Location band,
|
|
61
|
+
and its map when both coordinates are present, without writing the facts
|
|
62
|
+
callable. It applies to the default footer and to an app's own facts that
|
|
63
|
+
carry no address. An address in the app's own facts wins, whole, and
|
|
64
|
+
`address: false` there shows none.
|
|
65
|
+
- **docs/NEW_APP_SETUP.md has a Site Footer step (§ 10)**, and its layout ends
|
|
66
|
+
with `<%= studio_site_footer %>`. The sections after it moved down by one.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- `address: false` in the footer's facts shows no address instead of raising.
|
|
71
|
+
|
|
7
72
|
## 0.86.0 — 2026-10-03
|
|
8
73
|
|
|
9
74
|
### Added
|
data/README.md
CHANGED
|
@@ -41,8 +41,9 @@ resolved.
|
|
|
41
41
|
- **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
42
42
|
- **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
|
|
43
43
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
44
|
-
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer
|
|
44
|
+
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
45
45
|
- **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
|
|
46
|
+
- **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
|
|
46
47
|
|
|
47
48
|
## Configuration
|
|
48
49
|
|
|
@@ -137,6 +138,8 @@ Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpo
|
|
|
137
138
|
|
|
138
139
|
Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one. An app that keeps its own booking page names it with `Studio.booking_path` instead ([`docs/BOOKING.md`](docs/BOOKING.md)).
|
|
139
140
|
|
|
141
|
+
Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys/:slug`, helper `studio_survey_path`) and `Studio.draw_admin_survey_routes = true` to add the admin results panel (`/admin/surveys`, helpers `admin_surveys_path` / `admin_survey_path`) — both off by default because an app may already own those paths. Both need the `studio_survey_responses` table ([`docs/SURVEYS.md`](docs/SURVEYS.md)).
|
|
142
|
+
|
|
140
143
|
**Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
|
|
141
144
|
|
|
142
145
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The survey results panel (docs/SURVEYS.md). Admin-only, drawn only when
|
|
5
|
+
# Studio.draw_admin_survey_routes is on.
|
|
6
|
+
#
|
|
7
|
+
# GET /admin/surveys every survey: started, completed, rate
|
|
8
|
+
# GET /admin/surveys/:slug per-question breakdown, filterable
|
|
9
|
+
# GET /admin/surveys/:slug/export the same filtered responses as CSV
|
|
10
|
+
#
|
|
11
|
+
# A slug with stored responses but no definition left in app code still lists
|
|
12
|
+
# and exports (its breakdown needs the definition, so its page says so).
|
|
13
|
+
class AdminSurveysController < ::ApplicationController
|
|
14
|
+
before_action :require_admin
|
|
15
|
+
before_action :require_table
|
|
16
|
+
|
|
17
|
+
def index
|
|
18
|
+
started = Studio::SurveyResponse.group(:survey_slug).count
|
|
19
|
+
completed = Studio::SurveyResponse.completed.group(:survey_slug).count
|
|
20
|
+
slugs = (Studio.surveys.map(&:slug) + started.keys).uniq.sort
|
|
21
|
+
@rows = slugs.map do |slug|
|
|
22
|
+
s = started.fetch(slug, 0)
|
|
23
|
+
c = completed.fetch(slug, 0)
|
|
24
|
+
{ slug: slug, survey: Studio.survey(slug), started: s, completed: c,
|
|
25
|
+
rate: Studio::Survey::Breakdown.percent(c, s) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
@slug = params[:slug].to_s
|
|
31
|
+
@survey = Studio.survey(@slug)
|
|
32
|
+
@filters = filters
|
|
33
|
+
scope = filtered_scope
|
|
34
|
+
@started = scope.count
|
|
35
|
+
@completed = scope.completed.count
|
|
36
|
+
return unless @survey
|
|
37
|
+
|
|
38
|
+
@breakdown = Studio::Survey::Breakdown.new(@survey, breakdown_rows(scope))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def export
|
|
42
|
+
slug = params[:slug].to_s
|
|
43
|
+
survey = Studio.survey(slug)
|
|
44
|
+
@filters = filters
|
|
45
|
+
@slug = slug
|
|
46
|
+
rows = filtered_scope.includes(:user).order(:id).map do |r|
|
|
47
|
+
{ response_id: r.id, survey_version: r.survey_version, status: r.completed? ? "completed" : "in_progress",
|
|
48
|
+
started_at: r.started_at, completed_at: r.completed_at, respondent: r.respondent_label,
|
|
49
|
+
user_id: r.user_id, email_ref: r.email_ref, user_agent_class: r.user_agent_class, answers: r.answers }
|
|
50
|
+
end
|
|
51
|
+
csv = Studio::Survey::Export.new(survey, rows).to_csv
|
|
52
|
+
send_data csv, type: "text/csv; charset=utf-8",
|
|
53
|
+
filename: "survey-#{slug.parameterize}-#{Time.current.strftime("%Y%m%d")}.csv"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def require_table
|
|
59
|
+
return if Studio::SurveyResponse.table_ready?
|
|
60
|
+
|
|
61
|
+
raise Studio::SurveyResponse::MissingTable,
|
|
62
|
+
"studio_survey_responses is not installed — run bin/rails studio_engine:install:migrations && bin/rails db:migrate"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def filters
|
|
66
|
+
{
|
|
67
|
+
from: parse_date(params[:from]),
|
|
68
|
+
to: parse_date(params[:to]),
|
|
69
|
+
completed: params[:completed] == "1"
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_date(value)
|
|
74
|
+
Date.iso8601(value.to_s)
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Dates filter on started_at, inclusive of both whole days.
|
|
80
|
+
def filtered_scope
|
|
81
|
+
scope = Studio::SurveyResponse.for_survey(@slug)
|
|
82
|
+
scope = scope.where(started_at: @filters[:from].beginning_of_day..) if @filters[:from]
|
|
83
|
+
scope = scope.where(started_at: ..@filters[:to].end_of_day) if @filters[:to]
|
|
84
|
+
scope = scope.completed if @filters[:completed]
|
|
85
|
+
scope
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def breakdown_rows(scope)
|
|
89
|
+
scope.includes(:user).order(:id).map do |r|
|
|
90
|
+
{ answers: r.answers, respondent: r.respondent_label, at: r.completed_at || r.updated_at }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The public survey pages (docs/SURVEYS.md). Drawn only when
|
|
5
|
+
# Studio.draw_survey_routes is on.
|
|
6
|
+
#
|
|
7
|
+
# GET /surveys/:slug the survey: one question per screen with JS,
|
|
8
|
+
# a single form without it
|
|
9
|
+
# PATCH /surveys/:slug/answers/:key autosave one answer (JSON)
|
|
10
|
+
# POST /surveys/:slug submit: store every answer, complete, thank
|
|
11
|
+
# GET /surveys/:slug/thanks the thank-you screen and next action
|
|
12
|
+
#
|
|
13
|
+
# A response row is created on the FIRST ANSWER, not on page view, so a link
|
|
14
|
+
# preview bot or a bounce never counts as "started".
|
|
15
|
+
class SurveysController < ::ApplicationController
|
|
16
|
+
skip_before_action :require_authentication, raise: false
|
|
17
|
+
|
|
18
|
+
before_action :load_survey
|
|
19
|
+
before_action :require_table
|
|
20
|
+
before_action :require_respondent
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
@response = locate_response
|
|
24
|
+
return redirect_to(studio_survey_thanks_path(@survey.slug), status: :see_other) if @response&.completed?
|
|
25
|
+
|
|
26
|
+
@errors = {}
|
|
27
|
+
render_survey
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def answer
|
|
31
|
+
question = @survey.question(params[:key])
|
|
32
|
+
return render(json: { error: "unknown question" }, status: :not_found) unless question
|
|
33
|
+
|
|
34
|
+
response = open_response!
|
|
35
|
+
return render(json: { error: "already complete" }, status: :conflict) unless response
|
|
36
|
+
|
|
37
|
+
ok, error = rescue_survey_write(response) { response.record_answer(question, params[:value]) }
|
|
38
|
+
if ok
|
|
39
|
+
render json: { saved: true, key: question.key, answered: response.answers.keys & @survey.keys }
|
|
40
|
+
else
|
|
41
|
+
render json: { saved: false, error: error }, status: :unprocessable_entity
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def submit
|
|
46
|
+
response = open_response!
|
|
47
|
+
return redirect_to(studio_survey_thanks_path(@survey.slug), status: :see_other) unless response
|
|
48
|
+
|
|
49
|
+
submitted = submitted_answers
|
|
50
|
+
@errors = {}
|
|
51
|
+
Studio::SurveyResponse.transaction do
|
|
52
|
+
@survey.questions.each do |question|
|
|
53
|
+
next unless submitted.key?(question.key)
|
|
54
|
+
|
|
55
|
+
ok, error = response.record_answer(question, submitted[question.key])
|
|
56
|
+
@errors[question.key] = error unless ok
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
|
|
60
|
+
|
|
61
|
+
if @errors.empty? && response.complete!(@survey)
|
|
62
|
+
run_completed_hook(response)
|
|
63
|
+
redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
|
|
64
|
+
elsif response.completed?
|
|
65
|
+
# A racing submit completed it first; that one fired the hook.
|
|
66
|
+
redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
|
|
67
|
+
else
|
|
68
|
+
@response = response
|
|
69
|
+
render_survey(status: :unprocessable_entity)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def thanks
|
|
74
|
+
@response = locate_response
|
|
75
|
+
redirect_to(studio_survey_path(@survey.slug), status: :see_other) unless @response&.completed?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def load_survey
|
|
81
|
+
@survey = Studio.survey(params[:slug])
|
|
82
|
+
render :not_found, status: :not_found unless @survey
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def require_table
|
|
86
|
+
return if Studio::SurveyResponse.table_ready?
|
|
87
|
+
|
|
88
|
+
raise Studio::SurveyResponse::MissingTable,
|
|
89
|
+
"studio_survey_responses is not installed — run bin/rails studio_engine:install:migrations && bin/rails db:migrate"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Signed in, arrived by an attributed email link, or an anonymous survey.
|
|
93
|
+
def require_respondent
|
|
94
|
+
return if @survey.allow_anonymous? || survey_user || survey_ref.present?
|
|
95
|
+
|
|
96
|
+
render :sign_in_required, status: :unauthorized
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render_survey(status: :ok)
|
|
100
|
+
@resume_key = @errors.keys.first || @response&.resume_key(@survey)
|
|
101
|
+
@resume_index = @resume_key ? @survey.keys.index(@resume_key) : nil
|
|
102
|
+
render :show, status: status
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def survey_user
|
|
106
|
+
return @survey_user if defined?(@survey_user)
|
|
107
|
+
|
|
108
|
+
@survey_user = respond_to?(:current_user, true) ? current_user : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def survey_ref
|
|
112
|
+
return @survey_ref if defined?(@survey_ref)
|
|
113
|
+
|
|
114
|
+
@survey_ref = begin
|
|
115
|
+
Studio.survey_ref_resolver&.call(self).to_s.strip.presence
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
log_survey_error(e)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def token_key = "studio_survey_#{@survey.slug}"
|
|
123
|
+
|
|
124
|
+
def session_token
|
|
125
|
+
session[token_key] ||= SecureRandom.urlsafe_base64(24)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def locate_response
|
|
129
|
+
Studio::SurveyResponse.locate(@survey, user: survey_user, token: session[token_key])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The visitor's open response, created on demand; nil if theirs is complete.
|
|
133
|
+
def open_response!
|
|
134
|
+
existing = locate_response
|
|
135
|
+
return nil if existing&.completed?
|
|
136
|
+
|
|
137
|
+
response = existing || Studio::SurveyResponse.open_for!(
|
|
138
|
+
@survey, user: survey_user, token: session_token, email_ref: survey_ref, user_agent: request.user_agent
|
|
139
|
+
)
|
|
140
|
+
response.update!(email_ref: survey_ref.first(255)) if response.email_ref.blank? && survey_ref.present?
|
|
141
|
+
response
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def submitted_answers
|
|
145
|
+
raw = params[:answers]
|
|
146
|
+
return {} unless raw.respond_to?(:permit)
|
|
147
|
+
|
|
148
|
+
permitted = @survey.questions.map { |q| q.multi? ? { q.key => [] } : q.key }
|
|
149
|
+
raw.permit(*permitted).to_h
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def rescue_survey_write(target)
|
|
153
|
+
yield
|
|
154
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
155
|
+
log_survey_error(e, target: target)
|
|
156
|
+
[false, "Couldn't save that answer. Please try again."]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_completed_hook(response)
|
|
160
|
+
Studio.on_survey_completed&.call(response)
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
log_survey_error(e, target: response)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def log_survey_error(error, target: nil)
|
|
166
|
+
log = defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!) ? ::ErrorLog.capture!(error) : nil
|
|
167
|
+
log.update(target: target) if log && target && log.respond_to?(:target=)
|
|
168
|
+
Rails.logger.error("[studio-survey] #{error.class}: #{error.message}")
|
|
169
|
+
rescue StandardError
|
|
170
|
+
Rails.logger.error("[studio-survey] #{error.class}: #{error.message}")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -10,15 +10,18 @@ module Studio
|
|
|
10
10
|
# <%= studio_booking_popup %> the dialog those links open
|
|
11
11
|
# <%= studio_footer_map %> the map alone, e.g. on a contact page
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
# (
|
|
15
|
-
#
|
|
13
|
+
# THE FOOTER IS ON BY DEFAULT: with Studio.site_footer unset, the layout line
|
|
14
|
+
# renders the default footer (the site name, the logo, and the legal links the
|
|
15
|
+
# host has routes for), and `config.site_footer = false` turns it off. The
|
|
16
|
+
# booking helpers render NOTHING without Studio.booking_url, and the map
|
|
17
|
+
# nothing without an address, so a layout or a shared view can call any of
|
|
18
|
+
# them before an app has declared anything.
|
|
16
19
|
#
|
|
17
20
|
# Prefixed names for the reason Studio::GeoHelper gives: every helper module is
|
|
18
21
|
# included into every view.
|
|
19
22
|
module SiteFooterHelper
|
|
20
|
-
# The resolved facts (Studio::SiteFooter.resolve)
|
|
21
|
-
# request.
|
|
23
|
+
# The resolved facts (Studio::SiteFooter.resolve): the app's own, else the
|
|
24
|
+
# default footer's. nil when the footer is off. Resolved once per request.
|
|
22
25
|
#
|
|
23
26
|
# A callable that RAISES must not take every page down with it, because the
|
|
24
27
|
# footer is in the layout. In production the error is logged once per process
|
|
@@ -41,7 +44,7 @@ module Studio
|
|
|
41
44
|
# Where the footer shows: Studio.site_footer_visible, asked of this view. By
|
|
42
45
|
# default that is every page for a visitor and, for a signed-in viewer, only
|
|
43
46
|
# the controllers in Studio.site_footer_controllers (plus the booking page).
|
|
44
|
-
# Always false for an app that
|
|
47
|
+
# Always false for an app that turned the footer off.
|
|
45
48
|
def studio_show_site_footer?
|
|
46
49
|
return false if studio_site_footer_facts.nil?
|
|
47
50
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# One respondent's answers to one Studio::Survey (docs/SURVEYS.md).
|
|
5
|
+
#
|
|
6
|
+
# The survey is app code; this row is the only thing stored. Each answer is a
|
|
7
|
+
# self-describing snapshot — value, question label, type, and the option
|
|
8
|
+
# label(s) chosen — so a response stays readable after its survey changes.
|
|
9
|
+
class SurveyResponse < ApplicationRecord
|
|
10
|
+
self.table_name = "studio_survey_responses"
|
|
11
|
+
|
|
12
|
+
class MissingTable < StandardError; end
|
|
13
|
+
|
|
14
|
+
belongs_to :user, class_name: "::User", optional: true
|
|
15
|
+
|
|
16
|
+
validates :survey_slug, presence: true
|
|
17
|
+
validates :started_at, presence: true
|
|
18
|
+
|
|
19
|
+
scope :completed, -> { where.not(completed_at: nil) }
|
|
20
|
+
scope :in_progress, -> { where(completed_at: nil) }
|
|
21
|
+
scope :for_survey, ->(slug) { where(survey_slug: slug.to_s) }
|
|
22
|
+
|
|
23
|
+
before_validation { self.started_at ||= Time.current }
|
|
24
|
+
|
|
25
|
+
UA_BOT = /bot|crawl|spider|slurp|preview|facebookexternalhit|curl|wget|python|headless/i
|
|
26
|
+
UA_TABLET = /ipad|tablet|kindle|silk|(android(?!.*mobile))/i
|
|
27
|
+
UA_MOBILE = /mobi|iphone|ipod|android|blackberry|opera mini|iemobile/i
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def table_ready?
|
|
31
|
+
table_exists?
|
|
32
|
+
rescue ActiveRecord::ActiveRecordError, NameError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The coarse device class stored instead of the raw user agent.
|
|
37
|
+
def user_agent_class(user_agent)
|
|
38
|
+
ua = user_agent.to_s
|
|
39
|
+
return "unknown" if ua.strip.empty?
|
|
40
|
+
return "bot" if ua.match?(UA_BOT)
|
|
41
|
+
return "tablet" if ua.match?(UA_TABLET)
|
|
42
|
+
return "mobile" if ua.match?(UA_MOBILE)
|
|
43
|
+
|
|
44
|
+
"desktop"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The visitor's open response, or their latest completed one: by user
|
|
48
|
+
# first, then by the session token. An anonymous response found by token
|
|
49
|
+
# is claimed by the user who signs in mid-survey, and the claim drops the
|
|
50
|
+
# token: logout keeps the survey session key, so a token on a user's row
|
|
51
|
+
# would hand it to the next person to sign in on that browser.
|
|
52
|
+
def locate(survey, user: nil, token: nil)
|
|
53
|
+
scope = for_survey(survey.slug)
|
|
54
|
+
found = user && scope.where(user_id: user.id).order(completed_at: :desc, id: :desc).find_by(completed_at: nil)
|
|
55
|
+
found ||= token.present? && scope.where(session_token: token, user_id: nil).order(id: :desc).first
|
|
56
|
+
found ||= user && scope.where(user_id: user.id).order(id: :desc).first
|
|
57
|
+
return nil unless found
|
|
58
|
+
|
|
59
|
+
found.update!(user_id: user.id, session_token: nil) if user && found.user_id.nil? && found.open?
|
|
60
|
+
found
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Find the visitor's open response or start one. The partial unique
|
|
64
|
+
# indexes make a double-click race lose to the row that won.
|
|
65
|
+
def open_for!(survey, user: nil, token:, email_ref: nil, user_agent: nil)
|
|
66
|
+
existing = locate(survey, user: user, token: token)
|
|
67
|
+
return existing if existing&.open?
|
|
68
|
+
|
|
69
|
+
create!(survey_slug: survey.slug, survey_version: survey.version, user_id: user&.id,
|
|
70
|
+
session_token: (token unless user), email_ref: email_ref.to_s.strip.presence&.first(255),
|
|
71
|
+
user_agent_class: user_agent_class(user_agent), answers: {})
|
|
72
|
+
rescue ActiveRecord::RecordNotUnique
|
|
73
|
+
locate(survey, user: user, token: token) || raise
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def open? = completed_at.nil?
|
|
78
|
+
def completed? = completed_at.present?
|
|
79
|
+
|
|
80
|
+
def answer_entry(key) = (answers || {})[key.to_s]
|
|
81
|
+
def answer_value(key) = answer_entry(key)&.dig("value")
|
|
82
|
+
|
|
83
|
+
def answered?(question)
|
|
84
|
+
entry = answer_entry(question.key)
|
|
85
|
+
entry.is_a?(Hash) && !question.blank_value?(entry["value"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Stores one answer (or clears it, for a blank). Returns [ok, error]. Never
|
|
89
|
+
# touches a completed response: a finished survey is a record, not a draft.
|
|
90
|
+
def record_answer(question, raw)
|
|
91
|
+
return [false, "This survey is already complete."] if completed?
|
|
92
|
+
|
|
93
|
+
value, error = question.normalize(raw)
|
|
94
|
+
return [false, error] if error
|
|
95
|
+
|
|
96
|
+
next_answers = (answers || {}).dup
|
|
97
|
+
if value.nil?
|
|
98
|
+
next_answers.delete(question.key)
|
|
99
|
+
else
|
|
100
|
+
next_answers[question.key] = {
|
|
101
|
+
"value" => value, "label" => question.label, "type" => question.type.to_s,
|
|
102
|
+
"display" => question.display(value), "answered_at" => Time.current.utc.iso8601
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
self.answers = next_answers
|
|
106
|
+
self.current_key = question.key
|
|
107
|
+
save!
|
|
108
|
+
[true, nil]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def missing_required(survey)
|
|
112
|
+
survey.questions.select { |q| q.required? && !answered?(q) }.map(&:key)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The first unanswered question — where a returning respondent resumes.
|
|
116
|
+
def resume_key(survey)
|
|
117
|
+
survey.questions.find { |q| !answered?(q) }&.key || survey.questions.last.key
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Completes the response if every required question is answered. Returns
|
|
121
|
+
# true only when THIS call completed it (false if incomplete or already
|
|
122
|
+
# done). The flip is one conditional UPDATE, so two racing submits (a no-JS
|
|
123
|
+
# double click) cannot both win and fire on_survey_completed twice; the
|
|
124
|
+
# loser reloads and sees the winner's completed_at.
|
|
125
|
+
def complete!(survey)
|
|
126
|
+
return false if completed? || missing_required(survey).any?
|
|
127
|
+
|
|
128
|
+
now = Time.current
|
|
129
|
+
flipped = self.class.where(id: id, completed_at: nil)
|
|
130
|
+
.update_all(completed_at: now, survey_version: survey.version, updated_at: now)
|
|
131
|
+
unless flipped == 1
|
|
132
|
+
reload
|
|
133
|
+
return false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
self.completed_at = now
|
|
137
|
+
self.survey_version = survey.version
|
|
138
|
+
self.updated_at = now
|
|
139
|
+
clear_attribute_changes(%w[completed_at survey_version updated_at])
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# "username" for a signed-in respondent, "anonymous" otherwise.
|
|
144
|
+
def respondent_label
|
|
145
|
+
return "anonymous" unless user
|
|
146
|
+
|
|
147
|
+
user.try(:username).presence || user.try(:display_name).presence || "user ##{user.id}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# /admin/surveys — every survey with its funnel (Studio::AdminSurveysController).
|
|
2
|
+
"Started" counts responses with at least one answer: a row is created on the
|
|
3
|
+
first answer, never on a page view. %>
|
|
4
|
+
<% content_for(:title, "Surveys") %>
|
|
5
|
+
<div class="max-w-4xl mx-auto px-4 py-6" data-admin-surveys>
|
|
6
|
+
<h1 class="text-2xl font-extrabold text-heading mb-1">Surveys</h1>
|
|
7
|
+
<p class="text-sm text-secondary mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
|
|
8
|
+
|
|
9
|
+
<% if @rows.empty? %>
|
|
10
|
+
<div class="empty-state">
|
|
11
|
+
<p class="text-heading font-semibold mb-1">No surveys yet</p>
|
|
12
|
+
<p class="text-sm text-muted">Define one with <code>Studio.define_survey</code> — see the engine's docs/SURVEYS.md.</p>
|
|
13
|
+
</div>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="card overflow-x-auto">
|
|
16
|
+
<table class="w-full text-sm">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr class="text-left border-b border-subtle">
|
|
19
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium">Survey</th>
|
|
20
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium text-right">Started</th>
|
|
21
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium text-right">Completed</th>
|
|
22
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium text-right">Completion</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
<% @rows.each do |row| %>
|
|
27
|
+
<tr class="border-b border-subtle last:border-0" data-admin-survey-row="<%= row[:slug] %>">
|
|
28
|
+
<td class="px-4 py-3">
|
|
29
|
+
<a href="<%= admin_survey_path(row[:slug]) %>" class="font-semibold text-heading hover:underline"><%= row[:survey]&.title || row[:slug] %></a>
|
|
30
|
+
<div class="text-xs text-muted font-mono">/surveys/<%= row[:slug] %><% unless row[:survey] %> · definition removed<% end %></div>
|
|
31
|
+
</td>
|
|
32
|
+
<td class="px-4 py-3 text-right tabular-nums text-body" data-stat="started"><%= row[:started] %></td>
|
|
33
|
+
<td class="px-4 py-3 text-right tabular-nums text-body" data-stat="completed"><%= row[:completed] %></td>
|
|
34
|
+
<td class="px-4 py-3 text-right tabular-nums font-semibold text-heading" data-stat="rate"><%= row[:started].zero? ? "—" : "#{row[:rate]}%" %></td>
|
|
35
|
+
</tr>
|
|
36
|
+
<% end %>
|
|
37
|
+
</tbody>
|
|
38
|
+
</table>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|