studio-engine 0.96.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- data/app/views/studio/surveys/_script.html.erb +0 -309
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
itself on save), THIS section intercepts Save + Regenerate and submits them
|
|
8
8
|
via fetch — the page never navigates away, the live preview stays put, and
|
|
9
9
|
the outcome is a toast. The standalone page's own redirect is unchanged.
|
|
10
|
+
The editor is studio/theme_editor's dsThemeEditor factory, which
|
|
11
|
+
studio/alpine_shims publishes.
|
|
10
12
|
|
|
11
13
|
Rendered in two contexts:
|
|
12
14
|
* live in the host app (StyleController sets @theme_setting + @theme_defaults)
|
|
@@ -168,56 +170,4 @@
|
|
|
168
170
|
</div>
|
|
169
171
|
</div>
|
|
170
172
|
</div>
|
|
171
|
-
|
|
172
|
-
<script>
|
|
173
|
-
// Live-preview editor for the Style-guide Theme section. Seeded from the
|
|
174
|
-
// server-resolved role colors; updates the themeable role vars on the
|
|
175
|
-
// document as you type so every specimen on the page restyles instantly.
|
|
176
|
-
// The <input name> fields still carry the real values.
|
|
177
|
-
//
|
|
178
|
-
// Save-in-place: Save + Regenerate submit via fetch and stay on THIS page —
|
|
179
|
-
// preventDefault stops the browser navigating to /admin/theme (which is what
|
|
180
|
-
// bounced the operator off this section), the live preview persists, and the
|
|
181
|
-
// outcome is a toast (the engine's shared _flash toast primitive). The
|
|
182
|
-
// <form> POST still degrades gracefully if Alpine never loads.
|
|
183
|
-
function dsThemeEditor(seed) {
|
|
184
|
-
return {
|
|
185
|
-
colors: Object.assign({ primary: '#000', dark: '#0f172a', light: '#ffffff',
|
|
186
|
-
success: '#367E3A', warning: '#FF7C47', danger: '#EF4444',
|
|
187
|
-
accent: '#6366F1' }, seed || {}),
|
|
188
|
-
saving: false,
|
|
189
|
-
updatePreview() {
|
|
190
|
-
var d = document.documentElement.style;
|
|
191
|
-
d.setProperty('--color-cta', this.colors.primary);
|
|
192
|
-
d.setProperty('--color-cta-fill', this.colors.primary);
|
|
193
|
-
if (this.colors.success) d.setProperty('--color-success', this.colors.success);
|
|
194
|
-
if (this.colors.warning) { d.setProperty('--color-warning', this.colors.warning); d.setProperty('--color-warning-fill', this.colors.warning); }
|
|
195
|
-
if (this.colors.danger) { d.setProperty('--color-danger', this.colors.danger); d.setProperty('--color-danger-fill', this.colors.danger); }
|
|
196
|
-
},
|
|
197
|
-
persist(form, label) {
|
|
198
|
-
if (this.saving) return;
|
|
199
|
-
this.saving = true;
|
|
200
|
-
// Send the form's REAL method (the editor carries _method=patch) so we
|
|
201
|
-
// never depend on Rack::MethodOverride parsing a multipart fetch body.
|
|
202
|
-
var data = new FormData(form);
|
|
203
|
-
var override = data.get('_method');
|
|
204
|
-
var method = override ? String(override).toUpperCase() : 'POST';
|
|
205
|
-
if (override) data.delete('_method');
|
|
206
|
-
fetch(form.action, { method: method, body: data })
|
|
207
|
-
.then(function (res) {
|
|
208
|
-
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
209
|
-
return res;
|
|
210
|
-
})
|
|
211
|
-
.then((function () { this.toast('notice', label, 'Saved in place — the page stayed put.'); }).bind(this))
|
|
212
|
-
.catch((function (err) { this.toast('alert', 'Save failed', String((err && err.message) || err)); }).bind(this))
|
|
213
|
-
.finally((function () { this.saving = false; }).bind(this));
|
|
214
|
-
},
|
|
215
|
-
toast(type, title, message) {
|
|
216
|
-
window.dispatchEvent(new CustomEvent('toast', { detail: { type: type, title: title, message: message } }));
|
|
217
|
-
},
|
|
218
|
-
saveTheme(e) { this.persist(e.target, 'Theme saved'); },
|
|
219
|
-
regenerate(e) { this.persist(e.target, 'Theme cache cleared'); }
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
</script>
|
|
223
173
|
</section>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
<%# The editor is studio/theme_editor's themeEditor factory, seeded with the
|
|
2
|
+
server-resolved role colours. studio/alpine_shims publishes it. %>
|
|
3
|
+
<div class="max-w-5xl mx-auto px-4 py-8" x-data="themeEditor(<%= @theme_setting.resolved_colors.to_json %>)">
|
|
2
4
|
<div class="flex items-center justify-between mb-8">
|
|
3
5
|
<h1 class="text-2xl font-bold text-heading">Theme</h1>
|
|
4
6
|
<div class="flex gap-3">
|
|
@@ -300,84 +302,3 @@
|
|
|
300
302
|
|
|
301
303
|
</div>
|
|
302
304
|
</div>
|
|
303
|
-
|
|
304
|
-
<script>
|
|
305
|
-
function themeEditor() {
|
|
306
|
-
return {
|
|
307
|
-
colors: {
|
|
308
|
-
primary: '<%= @theme_setting.resolved_colors[:primary] %>',
|
|
309
|
-
success: '<%= @theme_setting.resolved_colors[:success] %>',
|
|
310
|
-
accent: '<%= @theme_setting.resolved_colors[:accent] %>',
|
|
311
|
-
warning: '<%= @theme_setting.resolved_colors[:warning] %>',
|
|
312
|
-
danger: '<%= @theme_setting.resolved_colors[:danger] %>',
|
|
313
|
-
dark: '<%= @theme_setting.resolved_colors[:dark] %>',
|
|
314
|
-
light: '<%= @theme_setting.resolved_colors[:light] %>'
|
|
315
|
-
},
|
|
316
|
-
|
|
317
|
-
resetColor(role, defaultVal) {
|
|
318
|
-
this.colors[role] = defaultVal;
|
|
319
|
-
this.updatePreview();
|
|
320
|
-
},
|
|
321
|
-
|
|
322
|
-
normalizeHex(role) {
|
|
323
|
-
let val = this.colors[role].trim();
|
|
324
|
-
if (!val.startsWith('#')) val = '#' + val;
|
|
325
|
-
if (/^#[0-9a-fA-F]{3}$/.test(val)) {
|
|
326
|
-
val = '#' + val[1] + val[1] + val[2] + val[2] + val[3] + val[3];
|
|
327
|
-
}
|
|
328
|
-
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
|
329
|
-
this.colors[role] = val;
|
|
330
|
-
}
|
|
331
|
-
this.updatePreview();
|
|
332
|
-
},
|
|
333
|
-
|
|
334
|
-
updatePreview() {
|
|
335
|
-
// Update live CSS vars on the document for immediate feedback
|
|
336
|
-
document.documentElement.style.setProperty('--color-cta', this.colors.primary);
|
|
337
|
-
document.documentElement.style.setProperty('--color-success', this.colors.success);
|
|
338
|
-
document.documentElement.style.setProperty('--color-warning', this.colors.warning || '#FF7C47');
|
|
339
|
-
document.documentElement.style.setProperty('--color-danger', this.colors.danger || '#EF4444');
|
|
340
|
-
// btn-warning/btn-danger paint --color-<role>-fill first (the resolver
|
|
341
|
-
// emits it for the engine defaults), so a typed colour must set it too
|
|
342
|
-
// or the specimens keep the default fill. Cleared: drop the override.
|
|
343
|
-
['warning', 'danger'].forEach((role) => {
|
|
344
|
-
if (this.colors[role]) document.documentElement.style.setProperty(`--color-${role}-fill`, this.colors[role]);
|
|
345
|
-
else document.documentElement.style.removeProperty(`--color-${role}-fill`);
|
|
346
|
-
});
|
|
347
|
-
},
|
|
348
|
-
|
|
349
|
-
darkPreviewStyle() {
|
|
350
|
-
return { backgroundColor: this.colors.dark, borderRadius: '0' };
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
darkSurfaceStyle() {
|
|
354
|
-
return {
|
|
355
|
-
backgroundColor: this.lightenColor(this.colors.dark, 0.3),
|
|
356
|
-
border: '1px solid rgba(255,255,255,0.1)'
|
|
357
|
-
};
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
lightPreviewStyle() {
|
|
361
|
-
return { backgroundColor: this.colors.light, borderRadius: '0' };
|
|
362
|
-
},
|
|
363
|
-
|
|
364
|
-
lightSurfaceStyle() {
|
|
365
|
-
return {
|
|
366
|
-
backgroundColor: '#ffffff',
|
|
367
|
-
border: '1px solid #e2e8f0'
|
|
368
|
-
};
|
|
369
|
-
},
|
|
370
|
-
|
|
371
|
-
lightenColor(hex, amount) {
|
|
372
|
-
hex = hex.replace('#', '');
|
|
373
|
-
const r = parseInt(hex.substring(0, 2), 16);
|
|
374
|
-
const g = parseInt(hex.substring(2, 4), 16);
|
|
375
|
-
const b = parseInt(hex.substring(4, 6), 16);
|
|
376
|
-
const nr = Math.round(r + (255 - r) * amount);
|
|
377
|
-
const ng = Math.round(g + (255 - g) * amount);
|
|
378
|
-
const nb = Math.round(b + (255 - b) * amount);
|
|
379
|
-
return '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
|
|
380
|
-
}
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
</script>
|
data/config/importmap.rb
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
# "@hotwired/stimulus" is the engine's Stimulus. A host that pins its own
|
|
11
11
|
# (stimulus-rails) wins, and the engine's controllers run on that copy.
|
|
12
12
|
#
|
|
13
|
+
# The vendored SortableJS is pinned the same way, as "sortablejs", and never
|
|
14
|
+
# preloaded: studio/board imports it dynamically, on a page that drags. The file
|
|
15
|
+
# is the classic build (app/assets/javascripts/studio/sortable.js), which assigns
|
|
16
|
+
# window.Sortable when it is imported.
|
|
17
|
+
#
|
|
13
18
|
# `pin`, not `pin_all_from`, so a host can override one: importmap-rails expands
|
|
14
19
|
# a pin_all_from directory AFTER every plain pin, so a directory pin would beat
|
|
15
20
|
# the host's own `pin` of the same name. Plain pins are last-drawn-wins, and the
|
|
@@ -22,3 +27,4 @@ Studio::Engine.javascript_module_logical_paths.each do |logical_path|
|
|
|
22
27
|
pin name, to: logical_path, preload: boot.include?(name)
|
|
23
28
|
end
|
|
24
29
|
pin "@hotwired/stimulus", to: "studio/vendor/stimulus.js", preload: true
|
|
30
|
+
pin "sortablejs", to: "studio/sortable.js", preload: false
|
data/docs/BOOKING.md
CHANGED
|
@@ -297,11 +297,12 @@ An app that enforces a policy must allow the frame:
|
|
|
297
297
|
|-----------|--------|-----|
|
|
298
298
|
| `frame-src` | `https://calendar.google.com` | The booking frame and the popup |
|
|
299
299
|
|
|
300
|
-
The primitives
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
300
|
+
The primitives carry no inline script: their behaviour is the same-origin
|
|
301
|
+
module `studio/booking`, started by the `booking` controller on the frame's
|
|
302
|
+
wrapper and the popup's dialog. Their own `<style>` block is inline and carries
|
|
303
|
+
no nonce, and a cropped wrapper carries its numbers in an inline `style`
|
|
304
|
+
attribute. A policy that forbids inline style needs `'unsafe-inline'` in
|
|
305
|
+
`style-src` for them (or hashes).
|
|
305
306
|
|
|
306
307
|
## Limits
|
|
307
308
|
|
data/docs/SITE_FOOTER.md
CHANGED
|
@@ -319,14 +319,19 @@ The frame, its crop, the popup, the `/schedule` page, an app's own booking page
|
|
|
319
319
|
|
|
320
320
|
## What it costs a page
|
|
321
321
|
|
|
322
|
-
- The footer's CSS
|
|
323
|
-
|
|
322
|
+
- The footer's CSS is inline, rendered once per page, and scoped (`.ftr-*`,
|
|
323
|
+
`.booking-*`). They read the theme's custom properties with a
|
|
324
324
|
fallback beside each, so the footer lays out the same in an app whose Tailwind
|
|
325
325
|
build has never seen these views. Do not restyle it with utilities that your
|
|
326
326
|
build may not emit.
|
|
327
|
-
-
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
- The map and the booking primitives run from ES modules (`studio/footer_map`,
|
|
328
|
+
`studio/booking`), each behind a controller the engine registers lazily: the
|
|
329
|
+
module is fetched only by a page that renders the element naming it
|
|
330
|
+
(`data-studio-controller="footer-map"` on the map, `"booking"` on the frame's
|
|
331
|
+
wrapper and the popup's dialog).
|
|
332
|
+
- A page with no address never names Leaflet: the map's styles are rendered
|
|
333
|
+
only with a map (`studio/site_footer/_map_assets`), and nothing fetches its
|
|
334
|
+
module. A page with no `booking_url` fetches no booking module.
|
|
330
335
|
- A facts callable that raises costs the footer, not the page: in production the
|
|
331
336
|
error is logged once and the page renders without a footer. In development and
|
|
332
337
|
test it raises.
|
|
@@ -370,18 +375,17 @@ origins (the booking frame's `frame-src` is in [`BOOKING.md`](BOOKING.md#content
|
|
|
370
375
|
Leaflet itself is same-origin (`script-src 'self'`, `style-src 'self'`), since it
|
|
371
376
|
is served from the app's own assets.
|
|
372
377
|
|
|
373
|
-
The footer
|
|
374
|
-
inline and carry no nonce,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
for the engine's head partial today.
|
|
378
|
+
The footer carries no inline script: its behaviour is same-origin modules.
|
|
379
|
+
Its own `<style>` blocks are inline and carry no nonce, and Leaflet positions
|
|
380
|
+
its tiles with inline `style` attributes. A policy that forbids inline style
|
|
381
|
+
needs `'unsafe-inline'` in `style-src` for them (or hashes).
|
|
378
382
|
|
|
379
383
|
## Coexisting with an app's own copy
|
|
380
384
|
|
|
381
385
|
An app that still renders a local footer partial uses the same
|
|
382
|
-
`data-footer-map` attribute. The engine's
|
|
386
|
+
`data-footer-map` attribute. The engine's module keeps out of its way: its guard
|
|
383
387
|
is an engine name (`__studioFooterMapsArmed`), and it acts only on
|
|
384
|
-
engine-rendered maps, which carry `data-leaflet-js`. The booking
|
|
388
|
+
engine-rendered maps, which carry `data-leaflet-js`. The booking module follows
|
|
385
389
|
the same rule ([`BOOKING.md`](BOOKING.md#coexisting-with-an-apps-own-copy)).
|
|
386
390
|
|
|
387
391
|
## Markup hooks
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.97.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -412,13 +412,39 @@ files:
|
|
|
412
412
|
- app/javascript/studio/alpine_shims.js
|
|
413
413
|
- app/javascript/studio/alpine_stores.js
|
|
414
414
|
- app/javascript/studio/application.js
|
|
415
|
+
- app/javascript/studio/board.js
|
|
416
|
+
- app/javascript/studio/booking.js
|
|
417
|
+
- app/javascript/studio/controllers/board_controller.js
|
|
418
|
+
- app/javascript/studio/controllers/booking_controller.js
|
|
419
|
+
- app/javascript/studio/controllers/email_banner_scale_controller.js
|
|
420
|
+
- app/javascript/studio/controllers/footer_map_controller.js
|
|
421
|
+
- app/javascript/studio/controllers/geo_settings_controller.js
|
|
422
|
+
- app/javascript/studio/controllers/hold_button_controller.js
|
|
423
|
+
- app/javascript/studio/controllers/link_preview_card_controller.js
|
|
424
|
+
- app/javascript/studio/controllers/link_sidebar_controller.js
|
|
415
425
|
- app/javascript/studio/controllers/modal_host_controller.js
|
|
416
426
|
- app/javascript/studio/controllers/nav_collapse_controller.js
|
|
427
|
+
- app/javascript/studio/controllers/survey_controller.js
|
|
428
|
+
- app/javascript/studio/controllers/toast_controller.js
|
|
429
|
+
- app/javascript/studio/email_banner.js
|
|
430
|
+
- app/javascript/studio/email_banner_scale.js
|
|
431
|
+
- app/javascript/studio/footer_map.js
|
|
432
|
+
- app/javascript/studio/geo_settings.js
|
|
417
433
|
- app/javascript/studio/head_chrome.js
|
|
434
|
+
- app/javascript/studio/hold_button.js
|
|
435
|
+
- app/javascript/studio/hold_button_hooks.js
|
|
436
|
+
- app/javascript/studio/lazy_controllers.js
|
|
437
|
+
- app/javascript/studio/leveling_activity.js
|
|
438
|
+
- app/javascript/studio/link_preview_card.js
|
|
439
|
+
- app/javascript/studio/link_sidebar.js
|
|
418
440
|
- app/javascript/studio/local_path.js
|
|
419
441
|
- app/javascript/studio/modal_host.js
|
|
420
442
|
- app/javascript/studio/nav_collapse.js
|
|
421
443
|
- app/javascript/studio/pinned_stack.js
|
|
444
|
+
- app/javascript/studio/stimulus.js
|
|
445
|
+
- app/javascript/studio/survey.js
|
|
446
|
+
- app/javascript/studio/theme_editor.js
|
|
447
|
+
- app/javascript/studio/toast.js
|
|
422
448
|
- app/javascript/studio/vendor/stimulus.js
|
|
423
449
|
- app/jobs/error_log_cleanup_job.rb
|
|
424
450
|
- app/jobs/studio/email_delivery_job.rb
|
|
@@ -488,6 +514,7 @@ files:
|
|
|
488
514
|
- app/views/studio/_cropper_assets.html.erb
|
|
489
515
|
- app/views/studio/_fizz_layer.html.erb
|
|
490
516
|
- app/views/studio/_hold_button.html.erb
|
|
517
|
+
- app/views/studio/_hold_button_guard.html.erb
|
|
491
518
|
- app/views/studio/_leveling_activity_assets.html.erb
|
|
492
519
|
- app/views/studio/_session_stamp.html.erb
|
|
493
520
|
- app/views/studio/admin_models/_arenas_table.html.erb
|
|
@@ -514,7 +541,6 @@ files:
|
|
|
514
541
|
- app/views/studio/emails/_banner_preview.html.erb
|
|
515
542
|
- app/views/studio/emails/_banner_scale.html.erb
|
|
516
543
|
- app/views/studio/emails/_recipient_picker.html.erb
|
|
517
|
-
- app/views/studio/emails/_recipient_repaint.html.erb
|
|
518
544
|
- app/views/studio/emails/_row.html.erb
|
|
519
545
|
- app/views/studio/emails/index.html.erb
|
|
520
546
|
- app/views/studio/emails/orphan.html.erb
|
|
@@ -601,7 +627,6 @@ files:
|
|
|
601
627
|
- app/views/studio/site_footer/_social_icon.html.erb
|
|
602
628
|
- app/views/studio/site_identities/edit.html.erb
|
|
603
629
|
- app/views/studio/surveys/_question.html.erb
|
|
604
|
-
- app/views/studio/surveys/_script.html.erb
|
|
605
630
|
- app/views/studio/surveys/_styles.html.erb
|
|
606
631
|
- app/views/studio/surveys/not_found.html.erb
|
|
607
632
|
- app/views/studio/surveys/show.html.erb
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
<%# Repaints EVERY row on the emails list when the example recipient changes.
|
|
2
|
-
|
|
3
|
-
The list shows each email's banner and subject as they would arrive. Both are
|
|
4
|
-
per-recipient — the banner greets by first name and the subject may too — so
|
|
5
|
-
a list rendered for one person is a list of half-truths about everyone else.
|
|
6
|
-
Changing the selection has to repaint all of them.
|
|
7
|
-
|
|
8
|
-
WHY THE TEMPLATES RIDE ON THE ROW
|
|
9
|
-
Each row carries its own header / fallback / subject templates as data
|
|
10
|
-
attributes, and this reads them from the DOM. The alternative was a second
|
|
11
|
-
JSON payload listing every email, which would have to be kept in step with
|
|
12
|
-
the rows themselves — and a row whose payload entry went missing would
|
|
13
|
-
silently stop repainting while still looking correct for whoever was selected
|
|
14
|
-
at page load.
|
|
15
|
-
|
|
16
|
-
The placeholder rule is the same one Studio::Banner applies server-side, and
|
|
17
|
-
the same one the detail page's editor applies. It is small, and it is pinned
|
|
18
|
-
on both sides. If they disagree, the server is right. %>
|
|
19
|
-
<script>
|
|
20
|
-
(function () {
|
|
21
|
-
function recipients(config) {
|
|
22
|
-
return {
|
|
23
|
-
targets: config.targets || [],
|
|
24
|
-
targetId: config.targetId,
|
|
25
|
-
pickerOpen: false,
|
|
26
|
-
|
|
27
|
-
init() {
|
|
28
|
-
this.paint();
|
|
29
|
-
this.$watch("targetId", () => this.paint());
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
target() {
|
|
33
|
-
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
|
|
34
|
-
},
|
|
35
|
-
|
|
36
|
-
firstName() {
|
|
37
|
-
return (this.target().name || "").trim().split(/\s+/)[0] || "";
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
resolve(template, fallback) {
|
|
41
|
-
var first = this.firstName();
|
|
42
|
-
var app = config.appName || "";
|
|
43
|
-
var text = (template || "").replace(/\{app\}/g, app);
|
|
44
|
-
if (first) return text.replace(/\{name\}/g, first);
|
|
45
|
-
if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
|
|
46
|
-
return text;
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
// A subject has no second field for the nameless case, so an unresolved
|
|
50
|
-
// placeholder is removed along with the punctuation holding it — the
|
|
51
|
-
// same two passes Studio::Banner.interpolate makes, so "Sign in, {name}"
|
|
52
|
-
// reads "Sign in" here exactly as it would in an inbox.
|
|
53
|
-
resolveSubject(template) {
|
|
54
|
-
var app = config.appName || "";
|
|
55
|
-
var text = (template || "").replace(/\{app\}/g, app);
|
|
56
|
-
var first = this.firstName();
|
|
57
|
-
if (first) return text.replace(/\{name\}/g, first);
|
|
58
|
-
return text.replace(/[,;:—-]?\s*\{name\}/g, "")
|
|
59
|
-
.replace(/^\s*[,;:—-]\s*/, "")
|
|
60
|
-
.replace(/\s+/g, " ")
|
|
61
|
-
.trim();
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
// A row's banner lives in an IFRAME — the email's table is its own
|
|
65
|
-
// document so it cannot nest rows inside the list. Same-origin srcdoc,
|
|
66
|
-
// so its nodes are reachable; contentDocument is null until the frame
|
|
67
|
-
// has parsed, which is why the load listener is wired as well as the
|
|
68
|
-
// immediate attempt.
|
|
69
|
-
bannerNode(row, selector) {
|
|
70
|
-
var frame = row.querySelector("iframe[data-email-banner-preview]");
|
|
71
|
-
if (frame) {
|
|
72
|
-
try { return frame.contentDocument && frame.contentDocument.querySelector(selector); }
|
|
73
|
-
catch (_) { return null; }
|
|
74
|
-
}
|
|
75
|
-
return row.querySelector(selector);
|
|
76
|
-
},
|
|
77
|
-
|
|
78
|
-
paintRow(row) {
|
|
79
|
-
var header = this.bannerNode(row, "[data-banner-header]");
|
|
80
|
-
var subject = row.querySelector("[data-row-subject]");
|
|
81
|
-
if (header) {
|
|
82
|
-
header.textContent = this.resolve(row.dataset.header, row.dataset.headerFallback);
|
|
83
|
-
}
|
|
84
|
-
if (subject) subject.textContent = this.resolveSubject(row.dataset.subject);
|
|
85
|
-
},
|
|
86
|
-
|
|
87
|
-
paint() {
|
|
88
|
-
var self = this;
|
|
89
|
-
this.$root.querySelectorAll("[data-email-row]").forEach(function (row) {
|
|
90
|
-
self.paintRow(row);
|
|
91
|
-
var frame = row.querySelector("iframe[data-email-banner-preview]");
|
|
92
|
-
if (frame && !frame.dataset.repaintBound) {
|
|
93
|
-
frame.dataset.repaintBound = "1";
|
|
94
|
-
frame.addEventListener("load", function () { self.paintRow(row); });
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
},
|
|
98
|
-
|
|
99
|
-
// Anything interactive keeps its own behaviour; everything else opens
|
|
100
|
-
// the email. Without the closest() guard, clicking Upload would both
|
|
101
|
-
// open the cropper and navigate away from it.
|
|
102
|
-
openRow(event, row) {
|
|
103
|
-
// The listener sits on the tbody, so a click can land between rows and
|
|
104
|
-
// arrive with no row at all.
|
|
105
|
-
if (!row) return;
|
|
106
|
-
if (event.target.closest("a, button, input, label, form")) return;
|
|
107
|
-
var path = row.dataset.emailPath;
|
|
108
|
-
if (path) window.location = path;
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
window.emailRecipients = recipients;
|
|
114
|
-
if (typeof Alpine !== "undefined") {
|
|
115
|
-
Alpine.data("emailRecipients", recipients);
|
|
116
|
-
} else {
|
|
117
|
-
document.addEventListener("alpine:init", function () {
|
|
118
|
-
Alpine.data("emailRecipients", recipients);
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
})();
|
|
122
|
-
</script>
|