studio-engine 0.82.1 → 0.84.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 +136 -0
- data/README.md +4 -0
- data/app/assets/javascripts/studio/leaflet.js +42 -0
- data/app/assets/stylesheets/studio/leaflet.css +693 -0
- data/app/controllers/studio/bookings_controller.rb +22 -0
- data/app/helpers/studio/site_footer_helper.rb +188 -0
- data/app/views/studio/booking/_assets.html.erb +196 -0
- data/app/views/studio/booking/_frame.html.erb +44 -0
- data/app/views/studio/booking/_popup.html.erb +34 -0
- data/app/views/studio/bookings/show.html.erb +22 -0
- data/app/views/studio/site_footer/_assets.html.erb +232 -0
- data/app/views/studio/site_footer/_footer.html.erb +100 -0
- data/app/views/studio/site_footer/_link.html.erb +16 -0
- data/app/views/studio/site_footer/_map.html.erb +22 -0
- data/app/views/studio/site_footer/_social_icon.html.erb +23 -0
- data/app/views/style/_site_footer.html.erb +102 -0
- data/app/views/style/_tricks.html.erb +3 -0
- data/lib/studio/booking.rb +207 -0
- data/lib/studio/engine.rb +2 -0
- data/lib/studio/site_footer.rb +350 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +132 -0
- metadata +17 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The site footer and the booking primitives, as view helpers. See
|
|
5
|
+
# docs/SITE_FOOTER.md and docs/BOOKING.md.
|
|
6
|
+
#
|
|
7
|
+
# <%= studio_site_footer %> the footer, where it should show
|
|
8
|
+
# <%= studio_booking_frame %> Google's booking page, inline
|
|
9
|
+
# <%= studio_booking_link "Book a call", class: "btn btn-primary" %>
|
|
10
|
+
# <%= studio_booking_popup %> the dialog those links open
|
|
11
|
+
# <%= studio_footer_map %> the map alone, e.g. on a contact page
|
|
12
|
+
#
|
|
13
|
+
# Every one of them renders NOTHING when its configuration is absent
|
|
14
|
+
# (Studio.site_footer, Studio.booking_url), so a layout or a shared view can
|
|
15
|
+
# call them before an app has declared anything.
|
|
16
|
+
#
|
|
17
|
+
# Prefixed names for the reason Studio::GeoHelper gives: every helper module is
|
|
18
|
+
# included into every view.
|
|
19
|
+
module SiteFooterHelper
|
|
20
|
+
# The resolved facts (Studio::SiteFooter.resolve), or nil. Resolved once per
|
|
21
|
+
# request.
|
|
22
|
+
#
|
|
23
|
+
# A callable that RAISES must not take every page down with it, because the
|
|
24
|
+
# footer is in the layout. In production the error is logged once per process
|
|
25
|
+
# and the page renders with no footer; locally it raises, so whoever wrote the
|
|
26
|
+
# callable sees it.
|
|
27
|
+
def studio_site_footer_facts
|
|
28
|
+
return @_studio_site_footer_facts if defined?(@_studio_site_footer_facts)
|
|
29
|
+
|
|
30
|
+
@_studio_site_footer_facts =
|
|
31
|
+
begin
|
|
32
|
+
Studio.site_footer_for(self)
|
|
33
|
+
rescue StandardError => error
|
|
34
|
+
raise if studio_site_footer_raise?
|
|
35
|
+
|
|
36
|
+
studio_site_footer_report(error)
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Where the footer shows: Studio.site_footer_visible, asked of this view. By
|
|
42
|
+
# default that is every page for a visitor and, for a signed-in viewer, only
|
|
43
|
+
# the controllers in Studio.site_footer_controllers (plus the booking page).
|
|
44
|
+
# Always false for an app that declared no footer.
|
|
45
|
+
def studio_show_site_footer?
|
|
46
|
+
return false if studio_site_footer_facts.nil?
|
|
47
|
+
|
|
48
|
+
Studio.site_footer_visible.call(self) ? true : false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The one layout line. Renders the footer (and, with a booking_url, the
|
|
52
|
+
# booking popup) where studio_show_site_footer? says it belongs.
|
|
53
|
+
def studio_site_footer
|
|
54
|
+
return unless studio_show_site_footer?
|
|
55
|
+
|
|
56
|
+
render "studio/site_footer/footer", facts: studio_site_footer_facts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The live map alone. With no arguments it maps the footer's address;
|
|
60
|
+
# pass lat:/lng: (and street:/city_line:/directions_url:) to map another.
|
|
61
|
+
# `class:` and `style:` size it: the element has no height of its own.
|
|
62
|
+
def studio_footer_map(address = nil, **options)
|
|
63
|
+
address = address ? Studio::SiteFooter.address(Studio::SiteFooter.symbolize(address)) : studio_site_footer_facts&.dig(:address)
|
|
64
|
+
return unless address && address[:map]
|
|
65
|
+
|
|
66
|
+
render "studio/site_footer/map", address: address, classes: options[:class], style: options[:style],
|
|
67
|
+
zoom: options[:zoom], controls: options.fetch(:controls, true)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ---- booking ------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def studio_booking? = Studio.booking_url.present?
|
|
73
|
+
|
|
74
|
+
# Google's embeddable page for the configured schedule (with gv=true).
|
|
75
|
+
def studio_booking_embed_url = Studio::Booking.embed_url(Studio.booking_url)
|
|
76
|
+
|
|
77
|
+
# Where a booking link goes when the popup cannot open (scripts off, a
|
|
78
|
+
# modified click, a page with no dialog): the app's booking page
|
|
79
|
+
# (Studio.booking_path, or the engine's /schedule when drawn), else Google's
|
|
80
|
+
# own page.
|
|
81
|
+
def studio_booking_fallback_path
|
|
82
|
+
Studio.booking_path_for(self) || Studio.booking_url
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The frame's accessible name.
|
|
86
|
+
def studio_booking_title(title = nil)
|
|
87
|
+
title.presence || studio_site_footer_facts&.dig(:booking_title) ||
|
|
88
|
+
"#{studio_booking_label} with #{studio_site_footer_name}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# What the booking links, the popup and the booking page call the act.
|
|
92
|
+
def studio_booking_label
|
|
93
|
+
studio_site_footer_facts&.dig(:booking_label) || Studio::SiteFooter::DEFAULT_BOOKING_LABEL
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The site's name: the footer's, else the site identity's title.
|
|
97
|
+
def studio_site_footer_name
|
|
98
|
+
studio_site_footer_facts&.dig(:name) || Studio.site_identity[:title]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The crop a frame renders with: { offset:, window:, frame_height: }, or nil
|
|
102
|
+
# for the whole frame at rest. `true` reads Studio.booking_crop; a Hash is a
|
|
103
|
+
# one-off; false or nil is no crop.
|
|
104
|
+
def studio_booking_crop(crop = true)
|
|
105
|
+
Studio::Booking.crop(crop == true ? Studio.booking_crop : crop)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The inline booking frame. `crop:` is true (the default: use
|
|
109
|
+
# Studio.booking_crop, which is no crop until the app declares one), false
|
|
110
|
+
# (the whole page at rest) or a Hash for this frame alone.
|
|
111
|
+
def studio_booking_frame(title: nil, crop: true)
|
|
112
|
+
return unless studio_booking?
|
|
113
|
+
|
|
114
|
+
render "studio/booking/frame", title: studio_booking_title(title), crop: studio_booking_crop(crop)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The booking dialog, once per page however often it is asked for. The
|
|
118
|
+
# footer renders it; call this yourself only on a page that has booking
|
|
119
|
+
# links and no footer.
|
|
120
|
+
def studio_booking_popup(label: nil, title: nil)
|
|
121
|
+
return unless studio_booking?
|
|
122
|
+
return if @_studio_booking_popup_rendered
|
|
123
|
+
|
|
124
|
+
@_studio_booking_popup_rendered = true
|
|
125
|
+
render "studio/booking/popup",
|
|
126
|
+
label: label.presence || studio_booking_label,
|
|
127
|
+
title: studio_booking_title(title)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A link that opens the booking popup in place. Its href is the fallback, so
|
|
131
|
+
# it is an ordinary link wherever the popup is unavailable. With no
|
|
132
|
+
# booking_url and no href there is nowhere to go, and it renders nothing.
|
|
133
|
+
#
|
|
134
|
+
# <%= studio_booking_link %> "Schedule a call"
|
|
135
|
+
# <%= studio_booking_link "Book a call", class: "btn btn-primary" %>
|
|
136
|
+
# <%= studio_booking_link "Book a call", contact_path %> your own fallback
|
|
137
|
+
def studio_booking_link(name = nil, href = nil, html_options = nil, **options, &block)
|
|
138
|
+
html_options, href = href, nil if href.is_a?(Hash)
|
|
139
|
+
html_options, name = name, nil if name.is_a?(Hash)
|
|
140
|
+
html_options = (html_options || {}).merge(options)
|
|
141
|
+
href ||= studio_booking_fallback_path
|
|
142
|
+
return if href.blank?
|
|
143
|
+
|
|
144
|
+
html_options[:data] = { booking_popup: true, studio_booking: true }.merge(html_options[:data] || {}) if studio_booking?
|
|
145
|
+
content = block ? capture(&block) : (name || studio_booking_label)
|
|
146
|
+
link_to(content, href, html_options)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ---- once-per-page assets -----------------------------------------------
|
|
150
|
+
|
|
151
|
+
def studio_site_footer_assets
|
|
152
|
+
return if @_studio_site_footer_assets_rendered
|
|
153
|
+
|
|
154
|
+
@_studio_site_footer_assets_rendered = true
|
|
155
|
+
render "studio/site_footer/assets"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def studio_booking_assets
|
|
159
|
+
return if @_studio_booking_assets_rendered
|
|
160
|
+
|
|
161
|
+
@_studio_booking_assets_rendered = true
|
|
162
|
+
render "studio/booking/assets"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def studio_site_footer_raise?
|
|
168
|
+
defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def studio_site_footer_report(error)
|
|
172
|
+
return if Studio::SiteFooterHelper.reported
|
|
173
|
+
|
|
174
|
+
Studio::SiteFooterHelper.reported = true
|
|
175
|
+
if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
176
|
+
::ErrorLog.capture!(error)
|
|
177
|
+
elsif defined?(Rails.logger) && Rails.logger
|
|
178
|
+
Rails.logger.error("[studio.site_footer] #{error.class}: #{error.message}")
|
|
179
|
+
end
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class << self
|
|
185
|
+
attr_accessor :reported
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<%# Styles and scripts for the booking frame and the booking popup. Rendered
|
|
2
|
+
once per page, by `studio_booking_assets` (the frame and the popup both ask).
|
|
3
|
+
Scoped CSS for the reason studio/site_footer/_assets gives. %>
|
|
4
|
+
<style>
|
|
5
|
+
/* The inline frame. Google's page cannot be themed, so the panel is white. */
|
|
6
|
+
/* content-box, so the heights below are the WINDOW onto the frame whatever
|
|
7
|
+
reset the host runs: under border-box the border would eat 2px of the crop. */
|
|
8
|
+
.booking-frame { box-sizing: content-box; margin-inline: auto; overflow: hidden; background: #fff; border-radius: .75rem;
|
|
9
|
+
border: 1px solid var(--color-border, rgb(127 127 127 / .25));
|
|
10
|
+
/* A booking link on this page scrolls here; land below the pinned navbar. */
|
|
11
|
+
scroll-margin-top: calc(var(--nav-bottom, 4rem) + 1rem); }
|
|
12
|
+
.booking-frame iframe { display: block; width: 100%; border: 0; height: 1200px; }
|
|
13
|
+
/* From 640px up Google lays the month beside the slots. A cropped wrapper
|
|
14
|
+
carries its own numbers (Studio::Booking.crop_style): how far the frame is
|
|
15
|
+
pulled up, the window's height, and the frame's full height. A wrapper with
|
|
16
|
+
no crop carries none, and shows the frame whole at the default height. */
|
|
17
|
+
@media (min-width: 640px) {
|
|
18
|
+
.booking-frame iframe { height: var(--booking-frame-height, 732px); }
|
|
19
|
+
.booking-frame-cropped { height: var(--booking-crop-window); transition: height .25s ease; }
|
|
20
|
+
.booking-frame-cropped iframe { margin-top: calc(-1 * var(--booking-crop-offset)); transition: margin-top .25s ease; }
|
|
21
|
+
.booking-frame-cropped.is-open { height: var(--booking-frame-height, 732px); }
|
|
22
|
+
.booking-frame-cropped.is-open iframe { margin-top: 0; }
|
|
23
|
+
}
|
|
24
|
+
/* Scripts off: the frame never gets its src, so it is hidden and a plain link
|
|
25
|
+
stands in its place (the <noscript> in studio/booking/_frame). */
|
|
26
|
+
.booking-frame-noscript { margin: 0; padding: 3rem 1.5rem; text-align: center; color: #111; }
|
|
27
|
+
.booking-frame-noscript a { color: #4338ca; font-weight: 700; text-decoration: underline; }
|
|
28
|
+
.booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; line-height: 1.25rem; color: var(--color-text-muted, inherit); }
|
|
29
|
+
.booking-frame-note a { color: var(--color-primary, #8b5cf6); }
|
|
30
|
+
.booking-frame-note a:hover { text-decoration: underline; }
|
|
31
|
+
|
|
32
|
+
/* The popup. */
|
|
33
|
+
.booking-popup { width: min(960px, calc(100vw - 2rem)); height: min(800px, calc(100vh - 3rem)); height: min(800px, calc(100dvh - 3rem)); max-width: none; max-height: none;
|
|
34
|
+
/* Tailwind's reset zeroes a dialog's margin, which is what centres it. */
|
|
35
|
+
margin: auto; inset: 0;
|
|
36
|
+
padding: 0; border: 1px solid rgb(127 127 127 / .3); border-radius: .75rem; overflow: hidden;
|
|
37
|
+
background: var(--color-surface, #fff); color: var(--color-text-body, inherit); }
|
|
38
|
+
.booking-popup[open] { display: flex; flex-direction: column; }
|
|
39
|
+
.booking-popup::backdrop { background: rgb(15 12 31 / .72); backdrop-filter: blur(3px); }
|
|
40
|
+
.booking-popup-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0;
|
|
41
|
+
padding: .6rem .75rem .6rem 1.1rem; }
|
|
42
|
+
.booking-popup-title { font-weight: 700; color: var(--color-text, inherit); }
|
|
43
|
+
/* THE WAY OUT HAS TO BE UNMISSABLE. Once focus is inside Google's frame the
|
|
44
|
+
Escape key goes to Google, not to this dialog, and nothing here can change
|
|
45
|
+
that. So Close is a solid, full-size button, not a quiet glyph, and a click
|
|
46
|
+
anywhere on the backdrop closes too. */
|
|
47
|
+
/* Ink on surface, inverted: the theme's text colour as the fill and its
|
|
48
|
+
surface as the label. That pair is the theme's own highest contrast in both
|
|
49
|
+
modes (white on the primary measured 3.10:1, under the 4.5:1 a label needs). */
|
|
50
|
+
.booking-popup-close { flex: none; min-height: 2.75rem; padding: .5rem 1.1rem; border-radius: .5rem; border: 0;
|
|
51
|
+
background: var(--color-text, #111827); color: var(--color-surface, #fff); font: inherit; font-size: .95rem; font-weight: 700; cursor: pointer; }
|
|
52
|
+
.booking-popup-close:hover { filter: brightness(1.12); }
|
|
53
|
+
.booking-popup-close:focus-visible { outline: 3px solid var(--color-primary, #8b5cf6); outline-offset: 2px; }
|
|
54
|
+
/* The page behind does not scroll while the dialog is open. Pure CSS, so every
|
|
55
|
+
way of closing it (the button, the backdrop, Escape) lifts the lock. On html
|
|
56
|
+
rather than body for the reason studio/modals/_host gives. */
|
|
57
|
+
html:has(dialog[data-booking-dialog][open]) { overflow: hidden; }
|
|
58
|
+
.booking-popup iframe { flex: 1; width: 100%; border: 0; background: #fff; }
|
|
59
|
+
@media (prefers-reduced-motion: reduce) { .booking-frame, .booking-frame iframe { transition: none; } }
|
|
60
|
+
</style>
|
|
61
|
+
|
|
62
|
+
<script>
|
|
63
|
+
// The inline frame: assign src after `load`, once the frame is near the
|
|
64
|
+
// viewport, and open the crop when focus moves into the frame.
|
|
65
|
+
//
|
|
66
|
+
// THE GUARDS ARE THE ENGINE'S OWN NAMES, AND THE ELEMENTS ARE THE ENGINE'S OWN
|
|
67
|
+
// (data-studio-booking), for the reason studio/site_footer/_assets gives for
|
|
68
|
+
// the map: an app that still renders a local booking script guards it with
|
|
69
|
+
// the unprefixed names and marks its frame, dialog and links with the same
|
|
70
|
+
// data-booking-* attributes. Sharing either lets one script stand the other
|
|
71
|
+
// down across a Turbo visit, or act on elements that are not its own.
|
|
72
|
+
(function () {
|
|
73
|
+
if (window.__studioBookingFramesArmed) return;
|
|
74
|
+
window.__studioBookingFramesArmed = true;
|
|
75
|
+
|
|
76
|
+
// ASK GOOGLE ONCE. Two things can ask for a frame: scrolling near it, and a
|
|
77
|
+
// booking link that jumps to it. Assigning src a second time, even to the
|
|
78
|
+
// same URL, is a second request, so whoever comes second finds it set.
|
|
79
|
+
function load(frame) {
|
|
80
|
+
if (!frame.getAttribute('src')) frame.src = frame.dataset.src;
|
|
81
|
+
}
|
|
82
|
+
window.__studioBookingLoad = load;
|
|
83
|
+
|
|
84
|
+
function arm() {
|
|
85
|
+
document.querySelectorAll('iframe[data-booking-frame][data-studio-booking][data-src]').forEach(function (frame) {
|
|
86
|
+
if (frame.__bookingArmed) return;
|
|
87
|
+
frame.__bookingArmed = true;
|
|
88
|
+
if (typeof IntersectionObserver !== 'function') { load(frame); return; }
|
|
89
|
+
var watcher = new IntersectionObserver(function (entries) {
|
|
90
|
+
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
91
|
+
watcher.disconnect();
|
|
92
|
+
load(frame);
|
|
93
|
+
}, { rootMargin: '200px' });
|
|
94
|
+
watcher.observe(frame);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function armAfterLoad() {
|
|
99
|
+
if (document.readyState === 'complete') arm();
|
|
100
|
+
else window.addEventListener('load', arm, { once: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Focus moving into the frame is the one signal a cross-origin frame gives
|
|
104
|
+
// its parent. It means the visitor has started using the calendar.
|
|
105
|
+
function openActive() {
|
|
106
|
+
var active = document.activeElement;
|
|
107
|
+
if (!active || !active.matches || !active.matches('iframe[data-booking-frame][data-studio-booking]')) return;
|
|
108
|
+
var wrap = active.closest('[data-booking-wrap][data-studio-booking]');
|
|
109
|
+
if (wrap) wrap.classList.add('is-open');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// THE WINDOW BLURS ONCE. Focus that moves from one frame straight into
|
|
113
|
+
// another never comes back through this window, so no second blur announces
|
|
114
|
+
// the second frame, and on a page with two cropped frames it would stay
|
|
115
|
+
// cropped under the visitor's cursor. So while focus is away in a frame and a
|
|
116
|
+
// cropped wrapper is still shut, look again a few times a second. It stops
|
|
117
|
+
// when focus returns here or nothing is left to open, and a page with one
|
|
118
|
+
// frame never starts it: that frame is open by then.
|
|
119
|
+
var watching = null;
|
|
120
|
+
function shut() { return document.querySelector('[data-booking-wrap][data-studio-booking][data-booking-crop]:not(.is-open)'); }
|
|
121
|
+
function stopWatching() { if (watching) { clearInterval(watching); watching = null; } }
|
|
122
|
+
|
|
123
|
+
window.addEventListener('blur', function () {
|
|
124
|
+
openActive();
|
|
125
|
+
var active = document.activeElement;
|
|
126
|
+
if (watching || !active || active.tagName !== 'IFRAME' || !shut()) return;
|
|
127
|
+
watching = setInterval(function () {
|
|
128
|
+
openActive();
|
|
129
|
+
if (!shut()) stopWatching();
|
|
130
|
+
}, 250);
|
|
131
|
+
});
|
|
132
|
+
window.addEventListener('focus', stopWatching);
|
|
133
|
+
|
|
134
|
+
document.addEventListener('turbo:load', armAfterLoad);
|
|
135
|
+
armAfterLoad();
|
|
136
|
+
})();
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<script>
|
|
140
|
+
// Any a[data-booking-popup] link opens the booking dialog instead of leaving
|
|
141
|
+
// the page. Delegated from document, so it survives Turbo body swaps. A
|
|
142
|
+
// modified click (new tab, new window) is left alone, and so is everything
|
|
143
|
+
// when the dialog is missing or <dialog> is unsupported: the link's own href
|
|
144
|
+
// then takes the visitor to the booking page.
|
|
145
|
+
//
|
|
146
|
+
// ON A PAGE THAT ALREADY SHOWS THE INLINE FRAME the link goes to that frame
|
|
147
|
+
// instead: it scrolls there, opens the crop and moves focus in. A popup would
|
|
148
|
+
// be a second copy of the calendar on top of the first.
|
|
149
|
+
(function () {
|
|
150
|
+
if (window.__studioBookingPopupArmed) return;
|
|
151
|
+
window.__studioBookingPopupArmed = true;
|
|
152
|
+
|
|
153
|
+
document.addEventListener('click', function (event) {
|
|
154
|
+
var link = event.target.closest && event.target.closest('a[data-booking-popup][data-studio-booking]');
|
|
155
|
+
if (!link) return;
|
|
156
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
157
|
+
|
|
158
|
+
var inline = document.querySelector('iframe[data-booking-frame][data-studio-booking]');
|
|
159
|
+
if (inline) {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
var wrap = inline.closest('[data-booking-wrap][data-studio-booking]');
|
|
162
|
+
if (wrap) wrap.classList.add('is-open');
|
|
163
|
+
// Before `load` the frame keeps waiting: its own script assigns src then.
|
|
164
|
+
if (document.readyState === 'complete' && window.__studioBookingLoad) window.__studioBookingLoad(inline);
|
|
165
|
+
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
166
|
+
(wrap || inline).scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' });
|
|
167
|
+
inline.focus({ preventScroll: true });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
var dialog = document.querySelector('dialog[data-booking-dialog][data-studio-booking]');
|
|
172
|
+
if (!dialog || typeof dialog.showModal !== 'function') return;
|
|
173
|
+
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
var frame = dialog.querySelector('iframe[data-booking-popup-frame]');
|
|
176
|
+
if (frame && !frame.getAttribute('src')) frame.src = frame.dataset.src;
|
|
177
|
+
if (!dialog.open) dialog.showModal();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A click on the backdrop lands on the dialog element itself.
|
|
181
|
+
document.addEventListener('click', function (event) {
|
|
182
|
+
if (event.target.matches && event.target.matches('dialog[data-booking-dialog][data-studio-booking]')) event.target.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Never snapshot the dialog open: a restored page would show it stuck. And
|
|
186
|
+
// never snapshot its frame loaded: a restored page would ask Google for a
|
|
187
|
+
// popup nobody has opened. The src goes back to waiting in data-src.
|
|
188
|
+
document.addEventListener('turbo:before-cache', function () {
|
|
189
|
+
var dialog = document.querySelector('dialog[data-booking-dialog][data-studio-booking]');
|
|
190
|
+
if (!dialog) return;
|
|
191
|
+
if (dialog.open) dialog.close();
|
|
192
|
+
var frame = dialog.querySelector('iframe[data-booking-popup-frame]');
|
|
193
|
+
if (frame) frame.removeAttribute('src');
|
|
194
|
+
});
|
|
195
|
+
})();
|
|
196
|
+
</script>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<%# Google's inline booking page for the app's appointment schedule
|
|
2
|
+
(Studio.booking_url). Rendered by `studio_booking_frame`. It fills its
|
|
3
|
+
container, so it lines up with the cards around it. Google lays it out by
|
|
4
|
+
width: month beside the slots from about 600px, stacked below that. It
|
|
5
|
+
cannot be themed, so it sits on a white panel in both themes.
|
|
6
|
+
|
|
7
|
+
WHOLE AT REST unless the app declares a crop (Studio.booking_crop, or
|
|
8
|
+
`crop:` on the call). `crop` here is the resolved crop, a Hash of offset,
|
|
9
|
+
window and frame_height, or nil.
|
|
10
|
+
|
|
11
|
+
CROPPED AT REST, WHOLE IN USE, when it has one. From 640px up the wrapper
|
|
12
|
+
shows only the "Select an appointment time" box: Google's header above it
|
|
13
|
+
and its credit lines below are clipped. Where that box sits belongs to one
|
|
14
|
+
schedule, so the numbers ride on the wrapper as custom properties and two
|
|
15
|
+
frames with different crops can share a page. The crop cannot stay on,
|
|
16
|
+
because the form that opens when a slot is picked is a dialog centred in the
|
|
17
|
+
frame's FULL height, so a cropped frame cuts off its title and its Book
|
|
18
|
+
button. A cross-origin frame reports no clicks, but the parent window does
|
|
19
|
+
lose focus to it; on that signal the wrapper opens to the full frame.
|
|
20
|
+
Below 640px Google stacks the month above the slots and nothing is cropped.
|
|
21
|
+
|
|
22
|
+
THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
|
|
23
|
+
party, and a frame that starts loading before the window's `load` event
|
|
24
|
+
holds that event open for as long as Google takes to answer. So the URL
|
|
25
|
+
waits in data-src and the script in studio/booking/_assets assigns it only
|
|
26
|
+
AFTER `load`, once the frame is near the viewport.
|
|
27
|
+
|
|
28
|
+
WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
|
|
29
|
+
empty frame and puts a plain link to Google's booking page in its place. %>
|
|
30
|
+
<%= studio_booking_assets %>
|
|
31
|
+
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
|
|
32
|
+
<iframe data-booking-frame data-studio-booking
|
|
33
|
+
data-src="<%= studio_booking_embed_url %>"
|
|
34
|
+
title="<%= title %>"
|
|
35
|
+
width="100%" frameborder="0"></iframe>
|
|
36
|
+
<noscript>
|
|
37
|
+
<style>.booking-frame { height: auto !important; } .booking-frame iframe { display: none !important; }</style>
|
|
38
|
+
<p class="booking-frame-noscript"><a href="<%= Studio.booking_url %>" target="_blank" rel="noopener">Open the booking page to pick a time</a></p>
|
|
39
|
+
</noscript>
|
|
40
|
+
</div>
|
|
41
|
+
<p class="booking-frame-note">
|
|
42
|
+
Scheduling by Google Calendar. Trouble seeing it?
|
|
43
|
+
<a href="<%= Studio.booking_url %>" target="_blank" rel="noopener">Open the booking page</a>.
|
|
44
|
+
</p>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# The booking popup: Google's appointment page in a dialog, opened by any
|
|
2
|
+
a[data-booking-popup] link on the page (studio/booking/_assets carries the
|
|
3
|
+
script). Rendered once per page by `studio_booking_popup`; the site footer
|
|
4
|
+
calls it.
|
|
5
|
+
|
|
6
|
+
It does what Google's own scheduling-button script does, without loading
|
|
7
|
+
that script, its stylesheet and its icon font on every public page: the
|
|
8
|
+
frame is requested only when the dialog first opens. The links keep their
|
|
9
|
+
own href, so with scripts off, or on a modified click, they are ordinary
|
|
10
|
+
links to the booking page.
|
|
11
|
+
|
|
12
|
+
A NATIVE <dialog>, NOT THE MODAL HOST (studio/modals/_host), on purpose. The
|
|
13
|
+
host needs Alpine, its store, and each modal registered inside the host's
|
|
14
|
+
block in the layout, so the footer would stop being one layout line and an
|
|
15
|
+
app with no host would have no popup. Its card is padded and scrolls, where
|
|
16
|
+
this body is a full-bleed frame. And it mounts content through
|
|
17
|
+
<template x-if>, which would build a new frame, and ask Google again, on
|
|
18
|
+
every open; this dialog keeps the loaded one. showModal() gives the top
|
|
19
|
+
layer, the inert page and the focus trap without any of that.
|
|
20
|
+
|
|
21
|
+
ESCAPE HAS A LIMIT NOTHING HERE CAN LIFT. Once focus is inside Google's
|
|
22
|
+
frame, key presses go to Google (it is another origin), so Escape no longer
|
|
23
|
+
reaches the dialog. The Close button and a click on the backdrop always
|
|
24
|
+
work, which is why the button is a solid one. %>
|
|
25
|
+
<%= studio_booking_assets %>
|
|
26
|
+
<dialog class="booking-popup" data-booking-dialog data-studio-booking aria-label="<%= label %>">
|
|
27
|
+
<form method="dialog" class="booking-popup-bar">
|
|
28
|
+
<span class="booking-popup-title"><%= label %></span>
|
|
29
|
+
<button type="submit" class="booking-popup-close" data-booking-close>Close ✕</button>
|
|
30
|
+
</form>
|
|
31
|
+
<iframe data-booking-popup-frame
|
|
32
|
+
data-src="<%= studio_booking_embed_url %>"
|
|
33
|
+
title="<%= title %>" frameborder="0"></iframe>
|
|
34
|
+
</dialog>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# /schedule — the booking page (Studio::BookingsController), drawn by
|
|
2
|
+
Studio.draw_booking_routes. It is where a booking link lands when the popup
|
|
3
|
+
cannot open. The frame takes the app's crop (Studio.booking_crop), as it
|
|
4
|
+
does anywhere. An app that wants different words around the frame renders
|
|
5
|
+
`studio_booking_frame` in a view of its own instead, and names that page in
|
|
6
|
+
Studio.booking_path. %>
|
|
7
|
+
<% label = studio_booking_label %>
|
|
8
|
+
<% content_for(:title, "#{label} · #{studio_site_footer_name}") %>
|
|
9
|
+
<% content_for(:full_width, true) %>
|
|
10
|
+
|
|
11
|
+
<section class="booking-page" data-booking-page>
|
|
12
|
+
<h1 class="booking-page-title"><%= label %></h1>
|
|
13
|
+
<p class="booking-page-lede">Pick a time that works and it goes straight on the calendar.</p>
|
|
14
|
+
<%= studio_booking_frame %>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.booking-page { max-width: 56rem; margin-inline: auto; padding: 4rem 1rem; }
|
|
19
|
+
.booking-page-title { margin: 0; text-align: center; font-size: 2.25rem; font-weight: 800; letter-spacing: -.025em;
|
|
20
|
+
color: var(--color-text, inherit); }
|
|
21
|
+
.booking-page-lede { margin: .75rem 0 2rem; text-align: center; font-size: 1.125rem; color: var(--color-text-secondary, inherit); }
|
|
22
|
+
</style>
|