studio-engine 0.96.2.rc1 → 0.97.0.rc1

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. data/app/views/studio/surveys/_script.html.erb +0 -309
@@ -1,309 +0,0 @@
1
- <%# locals: () %>
2
- <%# The survey stepper: progressive enhancement over studio/surveys/show.
3
- Plain JavaScript (no Alpine dependency), idempotent per root so a Turbo
4
- render that re-runs it never double-binds. Bound roots are tracked in a
5
- WeakSet, never in a DOM attribute: Turbo's snapshot cache clones the page,
6
- and a clone keeps every attribute but none of the listeners, so a marker
7
- in the DOM would make a restored page look set up while it is dead.
8
- Autosave is best effort: the final Submit posts the whole form, so a
9
- failed autosave loses nothing. %>
10
- <%= javascript_tag nonce: true do %>
11
- (function () {
12
- "use strict";
13
-
14
- // Shared across re-runs of this script (Turbo evaluates it on every visit).
15
- var bound = window.__studioSurveyBound || (window.__studioSurveyBound = new WeakSet());
16
-
17
- function init(root) {
18
- if (bound.has(root)) return;
19
- bound.add(root);
20
-
21
- var form = root.querySelector("[data-survey-form]");
22
- var steps = Array.prototype.slice.call(root.querySelectorAll("[data-survey-step]"));
23
- // Every step is a question: the survey opens on question 1, no intro screen.
24
- var total = steps.length;
25
- var head = root.querySelector("[data-survey-head]");
26
- var back = root.querySelector("[data-survey-back]");
27
- var next = root.querySelector("[data-survey-next]");
28
- var submit = root.querySelector("[data-survey-submit]");
29
- var top = root.querySelector("[data-survey-top]");
30
- var bar = root.querySelector("[data-survey-progress]");
31
- var fill = root.querySelector("[data-survey-progress-fill]");
32
- var count = root.querySelector("[data-survey-count]");
33
- var status = root.querySelector("[data-survey-status]");
34
- var hint = root.querySelector("[data-survey-hint]");
35
- // A page restored from Turbo's cache comes back on the step it was left
36
- // on; resume there rather than at the server's original resume point.
37
- var restored = -1;
38
- // The GLOBAL token, not the form's: with per_form_csrf_tokens on, the
39
- // form's hidden token is bound to the POST and refused on the PATCH.
40
- var csrf = root.dataset.csrf || "";
41
- var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
42
- var saved = {};
43
- var timers = {};
44
- var current = -1;
45
- var pointerPick = false;
46
- var submitting = false;
47
-
48
- form.noValidate = true;
49
- if (root.classList.contains("is-enhanced")) {
50
- Array.prototype.forEach.call(root.querySelectorAll("[data-survey-step]"), function (step, i) {
51
- if (step.classList.contains("is-active")) restored = i;
52
- });
53
- }
54
- root.classList.add("is-enhanced");
55
- top.hidden = false;
56
-
57
- function typeOf(step) { return step.dataset.type || ""; }
58
- function isChoice(step) { return /^(emoji_scale|rating|choice)$/.test(typeOf(step)); }
59
- function isMulti(step) { return typeOf(step) === "multi_choice"; }
60
- function inputs(step) { return Array.prototype.slice.call(step.querySelectorAll("input.studio-survey__input")); }
61
-
62
- function valueOf(step) {
63
- if (isMulti(step)) return inputs(step).filter(function (i) { return i.checked; }).map(function (i) { return i.value; });
64
- if (isChoice(step)) {
65
- var picked = inputs(step).filter(function (i) { return i.checked; })[0];
66
- return picked ? picked.value : "";
67
- }
68
- var field = step.querySelector("[data-survey-text]");
69
- return field ? field.value.trim() : "";
70
- }
71
-
72
- function answered(step) {
73
- var v = valueOf(step);
74
- return Array.isArray(v) ? v.length > 0 : v !== "";
75
- }
76
-
77
- function setError(step, message) {
78
- var el = step.querySelector("[data-survey-error]");
79
- if (!el) return;
80
- el.textContent = message || "";
81
- el.hidden = !message;
82
- step.classList.toggle("is-invalid", !!message);
83
- }
84
-
85
- function save(step) {
86
- var key = step.dataset.key;
87
- if (!key || submitting) return Promise.resolve(true);
88
- var value = valueOf(step);
89
- var fingerprint = JSON.stringify(value);
90
- if (saved[key] === fingerprint) return Promise.resolve(true);
91
-
92
- var body = new FormData();
93
- if (Array.isArray(value)) {
94
- if (value.length === 0) body.append("value[]", "");
95
- value.forEach(function (v) { body.append("value[]", v); });
96
- } else {
97
- body.append("value", value);
98
- }
99
- var url = root.dataset.answerBase + encodeURIComponent(key);
100
- status.textContent = "Saving…";
101
- return fetch(url, {
102
- method: "PATCH",
103
- body: body,
104
- credentials: "same-origin",
105
- headers: { "Accept": "application/json", "X-CSRF-Token": csrf }
106
- }).then(function (res) {
107
- // fetch resolves on a 4xx/5xx — the status is the only signal.
108
- if (!res.ok) {
109
- return res.json().catch(function () { return {}; }).then(function (data) {
110
- if (res.status === 422 && data.error) setError(step, data.error);
111
- status.textContent = "Not saved yet — your answers still go in when you submit.";
112
- return false;
113
- });
114
- }
115
- saved[key] = fingerprint;
116
- status.textContent = "Saved";
117
- return true;
118
- }).catch(function () {
119
- status.textContent = "Offline? Your answers still go in when you submit.";
120
- return false;
121
- });
122
- }
123
-
124
- function render(index, direction) {
125
- steps.forEach(function (step, i) {
126
- var active = i === index;
127
- step.hidden = !active;
128
- step.classList.toggle("is-active", active);
129
- if (active) {
130
- if (direction && !reduce) step.dataset.dir = direction; else delete step.dataset.dir;
131
- }
132
- });
133
- current = index;
134
- var first = index === 0;
135
- var last = index === steps.length - 1;
136
- // Nothing comes before question 1, so it has no Back. Next keeps the
137
- // right edge on its own (margin-left: auto), so the row does not shift.
138
- back.hidden = first;
139
- next.hidden = last;
140
- submit.hidden = !last;
141
- // The title and intro lead question 1 only; later screens show the
142
- // question alone. The head steps into a visual clip rather than
143
- // `hidden`, so the page keeps its h1 for a screen reader on every step.
144
- if (head) head.classList.toggle("studio-survey__head--later", !first);
145
- // The number-key shortcut only means something on a choice or scale.
146
- var step = steps[index];
147
- hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
148
-
149
- var position = index + 1;
150
- fill.style.width = (total ? (position / total) * 100 : 0) + "%";
151
- bar.setAttribute("aria-valuenow", String(position));
152
- count.textContent = "Question " + position + " of " + total;
153
-
154
- var focusTarget = steps[index].querySelector("[data-survey-focus]");
155
- if (focusTarget && direction) focusTarget.focus({ preventScroll: true });
156
- if (direction) window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
157
- }
158
-
159
- function go(index) {
160
- if (index < 0 || index >= steps.length || index === current) return;
161
- render(index, index > current ? "fwd" : "back");
162
- }
163
-
164
- function forward() {
165
- var step = steps[current];
166
- if (step.dataset.key) {
167
- if (step.dataset.required === "true" && !answered(step)) {
168
- setError(step, "This one is required.");
169
- var first = step.querySelector("input:not([type=hidden]), textarea");
170
- if (first) first.focus();
171
- return;
172
- }
173
- setError(step, "");
174
- }
175
- if (current === steps.length - 1) {
176
- // No autosave here: the POST carries every answer, and a PATCH racing
177
- // it would land after completion and be refused.
178
- submitForm();
179
- } else {
180
- if (step.dataset.key) save(step);
181
- go(current + 1);
182
- }
183
- }
184
-
185
- function submitForm() {
186
- if (form.requestSubmit) form.requestSubmit(submit); else form.submit();
187
- }
188
-
189
- // The first required question still missing an answer, for a Submit that
190
- // jumps ahead (Enter on the last step, or a mid-survey reload).
191
- form.addEventListener("submit", function (event) {
192
- for (var i = 0; i < steps.length; i++) {
193
- if (steps[i].dataset.required === "true" && !answered(steps[i])) {
194
- event.preventDefault();
195
- go(i);
196
- setError(steps[i], "This one is required.");
197
- return;
198
- }
199
- }
200
- Object.keys(timers).forEach(function (key) { clearTimeout(timers[key]); });
201
- submitting = true;
202
- submit.disabled = true;
203
- submit.textContent = "Sending…";
204
- });
205
-
206
- back.addEventListener("click", function () { go(current - 1); });
207
- next.addEventListener("click", forward);
208
-
209
- root.addEventListener("pointerdown", function (event) {
210
- pointerPick = !!event.target.closest("[data-survey-choice]");
211
- });
212
-
213
- root.addEventListener("change", function (event) {
214
- var step = event.target.closest("[data-survey-step]");
215
- if (!step || !step.dataset.key || step.hidden) return;
216
- setError(step, "");
217
- if (isChoice(step)) {
218
- save(step);
219
- // Advance on a tap or a number key, never on arrow keys: arrows move
220
- // through a radio group and must not jump the reader off the question.
221
- if (pointerPick) {
222
- pointerPick = false;
223
- setTimeout(function () { if (steps[current] === step) forward(); }, reduce ? 0 : 260);
224
- }
225
- } else if (isMulti(step)) {
226
- save(step);
227
- }
228
- });
229
-
230
- root.addEventListener("input", function (event) {
231
- var field = event.target.closest("[data-survey-text]");
232
- if (!field) return;
233
- var step = field.closest("[data-survey-step]");
234
- var counter = step.querySelector("[data-survey-counter]");
235
- if (counter) counter.textContent = String(field.value.length);
236
- clearTimeout(timers[step.dataset.key]);
237
- timers[step.dataset.key] = setTimeout(function () { save(step); }, 900);
238
- });
239
-
240
- function onKey(event) {
241
- if (event.defaultPrevented || event.altKey) return;
242
- // A tap on an already-chosen option fires pointerdown but no change, so
243
- // the flag would outlive it and make the next arrow key advance.
244
- pointerPick = false;
245
- var step = steps[current];
246
- var target = event.target;
247
- var inText = target.matches && target.matches("textarea, input[type=text]");
248
-
249
- if (event.key === "Enter") {
250
- if (target.tagName === "BUTTON" || target.tagName === "A") return;
251
- if (target.tagName === "TEXTAREA" && !(event.metaKey || event.ctrlKey)) return;
252
- event.preventDefault();
253
- forward();
254
- return;
255
- }
256
-
257
- if (inText || event.metaKey || event.ctrlKey || !/^[1-9]$/.test(event.key)) return;
258
- if (!step.dataset.key || !(isChoice(step) || isMulti(step))) return;
259
- var option = inputs(step)[parseInt(event.key, 10) - 1];
260
- if (!option) return;
261
- event.preventDefault();
262
- option.checked = isMulti(step) ? !option.checked : true;
263
- option.focus({ preventScroll: true });
264
- pointerPick = isChoice(step);
265
- option.dispatchEvent(new Event("change", { bubbles: true }));
266
- }
267
-
268
- root.addEventListener("keydown", onKey);
269
- // The survey opens on question 1 with nothing focused, so a first key
270
- // lands on <body>, outside the root. Take those keys too, and only those:
271
- // a key aimed at any other control on the page is not the survey's. The
272
- // listener retires itself once a Turbo visit has taken this root away.
273
- function onPageKey(event) {
274
- if (!root.isConnected) { document.removeEventListener("keydown", onPageKey); return; }
275
- var target = event.target;
276
- if (target === document.body || target === document.documentElement) onKey(event);
277
- }
278
- document.addEventListener("keydown", onPageKey);
279
-
280
- var resume = root.dataset.resumeIndex;
281
- // resume-index is the question's own index, and every step is a question.
282
- var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10);
283
- if (!(start >= 0 && start < steps.length)) start = 0;
284
- var hasErrors = root.dataset.hasErrors === "true";
285
- // A fresh visitor lands on question 1; a returning one on their next
286
- // question, and a failed submit on the first question it flagged.
287
- if (restored >= 0) start = restored;
288
- render(start, null);
289
- if (restored < 0 && (start > 0 || hasErrors)) {
290
- var focusTarget = steps[current].querySelector("[data-survey-focus]");
291
- if (focusTarget) focusTarget.focus({ preventScroll: true });
292
- if (start > 0 && !hasErrors) status.textContent = "Welcome back — picking up where you left off.";
293
- }
294
- // Answers already on the page are already stored.
295
- steps.forEach(function (step) { if (step.dataset.key) saved[step.dataset.key] = JSON.stringify(valueOf(step)); });
296
- }
297
-
298
- function boot() {
299
- Array.prototype.forEach.call(document.querySelectorAll("[data-studio-survey]"), init);
300
- }
301
-
302
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
303
- // Turbo re-runs this script on every visit; bind the turbo:load hook once.
304
- if (!window.__studioSurveyTurboHook) {
305
- window.__studioSurveyTurboHook = true;
306
- document.addEventListener("turbo:load", boot);
307
- }
308
- })();
309
- <% end %>