You need to sign in or sign up before continuing.
studio-engine 0.83.0 → 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 +81 -0
- data/README.md +3 -2
- data/app/helpers/studio/site_footer_helper.rb +18 -10
- data/app/views/studio/booking/_assets.html.erb +43 -9
- data/app/views/studio/booking/_frame.html.erb +12 -9
- data/app/views/studio/bookings/show.html.erb +4 -2
- data/app/views/studio/site_footer/_assets.html.erb +33 -10
- data/app/views/studio/site_footer/_footer.html.erb +4 -3
- data/app/views/studio/site_footer/_link.html.erb +3 -1
- data/app/views/style/_site_footer.html.erb +35 -12
- data/lib/studio/booking.rb +164 -4
- data/lib/studio/site_footer.rb +63 -7
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +43 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 440b221a14bf9d80f6e58358f49d7b84f6495361e8d8a1a68e00b3980e02f024
|
|
4
|
+
data.tar.gz: 1efda067ddfb1277ceb5cfd6b399195689081c3d02b077489e75ff0c756aaf19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bde626a90189833853f31e49c8767d72fe48f82f99b06a3c7b3c282a790b55dc8479fcf066c12d7fe5324e7f84e6aa1c2f22536fa2af98908cb6c7cf237f30b2
|
|
7
|
+
data.tar.gz: 5e5034bebe474a62d7821e9e8cc3efd23f23a2daad1ac709a335bc88f52c8c816e37c121ae710ddc0643d5fad26a0912297dd47c6451835b7af23f0bd294fa14
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,87 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.84.0 — 2026-10-01
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **BREAKING for a 0.83.0 consumer that shows the booking frame: the frame is no
|
|
12
|
+
longer cropped by default.** 0.83.0 cropped every `studio_booking_frame` to a
|
|
13
|
+
414px window, 205px down a 732px frame. Those three numbers were measured from
|
|
14
|
+
one Google schedule, and on a schedule with a different header they sliced the
|
|
15
|
+
heading and exposed blank space. A crop that fits one schedule must not be a
|
|
16
|
+
default, so the frame now shows Google's whole page at rest until the app
|
|
17
|
+
declares its own crop. To keep exactly what 0.83.0 drew, add:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
config.booking_crop = { top: 211, bottom: 613, frame_height: 732 }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Better, measure the app's own schedule (below): 613 was that schedule's box
|
|
24
|
+
with five slots showing, and its fullest day has eight, so the 0.83.0 window
|
|
25
|
+
cut off the last three. `crop: false` still shows the whole frame; `crop:
|
|
26
|
+
true`, the default, now means "the app's crop". See *Upgrading from 0.83.0* in
|
|
27
|
+
[`docs/BOOKING.md`](docs/BOOKING.md).
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **`config.booking_crop`: the booking frame's crop, per site.** A Hash of
|
|
32
|
+
`top:`, `bottom:` and `frame_height:`, in pixels of Google's own page: the
|
|
33
|
+
"Select an appointment time" box's top, its bottom on its fullest day, and the
|
|
34
|
+
page's height that day. The window is that box plus 6px on each edge. Because
|
|
35
|
+
`bottom` is the tallest the box gets, a day with fewer slots shows a strip of
|
|
36
|
+
Google's credit lines under a shorter box and never a cut one.
|
|
37
|
+
`studio_booking_frame crop:` takes `false`, `true` (the app's crop) or a Hash
|
|
38
|
+
for one frame. The numbers ride on each wrapper as custom properties, so two
|
|
39
|
+
frames with different crops share a page. The engine's `/schedule` honours the
|
|
40
|
+
app's crop; the popup is never cropped. A crop that cannot be one (not numbers,
|
|
41
|
+
negative, bottom not below top or outside the frame, a window as tall as the
|
|
42
|
+
frame) shows the frame whole and is logged once.
|
|
43
|
+
- **`bin/booking-crop-measure <booking url>`** prints that Hash. It opens Google's
|
|
44
|
+
embedded page at two widths, selects every bookable day of the next three
|
|
45
|
+
months, and reports the month-grid rows and the slot rows of the fullest day it
|
|
46
|
+
measured against. It reaches the live Google page, so it refuses to run in CI.
|
|
47
|
+
- **`config.booking_path`: an app's own booking page.** A path, or a callable
|
|
48
|
+
receiving the view. An app that renders `studio_booking_frame` in its own view,
|
|
49
|
+
rather than drawing the engine's `/schedule`, names that page here and gets
|
|
50
|
+
what the engine's page had: a footer link to it opens the popup, it is
|
|
51
|
+
`studio_booking_link`'s fallback href, and a signed-in viewer keeps the footer
|
|
52
|
+
on it. With neither set, nothing changes. It must be a local path (one leading
|
|
53
|
+
`/`): a scheme or a protocol-relative `//host` is refused, from a String at
|
|
54
|
+
assignment and from a callable on each request.
|
|
55
|
+
- [`docs/BOOKING.md`](docs/BOOKING.md): booking as a primitive of its own,
|
|
56
|
+
adoptable without the footer. Setting the schedule up in Google, sharing other
|
|
57
|
+
calendars in for conflict checking, the booking URL, measuring the crop, CSP,
|
|
58
|
+
and the limits. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) keeps the footer.
|
|
59
|
+
- `/admin/style` shows the frame's two states, whole and cropped, as drawings.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **A footer link column no longer breaks an email address or a URL mid-word.**
|
|
64
|
+
The link columns were equal `minmax(0, 1fr)` tracks with `overflow-wrap:
|
|
65
|
+
anywhere`, so an address in a column broke in two at every width. The rows are
|
|
66
|
+
unchanged (two link columns to a row on a phone, one row from 768px, beside the
|
|
67
|
+
brand from 1024px), but a track is now a plain `fr`, at least as wide as its
|
|
68
|
+
longest word, and an address is kept on one line: its column grows to hold it
|
|
69
|
+
and its neighbours give way. The first column is 1.5 shares by default and a
|
|
70
|
+
column may set its own, `[ "Contact", links, { width: 2 } ]`. More than four
|
|
71
|
+
columns wrap four to a row under the brand. An app that overrode 0.83.0's
|
|
72
|
+
tracks from its own stylesheet may leave that block (it draws the same rows) or
|
|
73
|
+
delete it once it is on this version; while it stays, `width:` hints do nothing.
|
|
74
|
+
[`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) lists the stable class hooks.
|
|
75
|
+
- **A booking link that jumps to an inline frame asks Google once.** When the
|
|
76
|
+
frame had never been scrolled near, the link assigned its `src` and the
|
|
77
|
+
still-armed observer assigned it again as the frame scrolled into view: two
|
|
78
|
+
requests for one calendar. Whoever comes second now finds it set.
|
|
79
|
+
- **The footer and the booking note set their own line heights.** The Location
|
|
80
|
+
heading inherited the page's (a 45px line box at 1.5; it is 2.25rem, 36px,
|
|
81
|
+
now), and the legal line, the © line and the "Scheduling by Google Calendar"
|
|
82
|
+
note were each a pixel taller per line than their 1.25rem.
|
|
83
|
+
- **A second cropped frame on a page opens when focus moves into it from the
|
|
84
|
+
first.** Focus that goes from one frame straight into another never passes
|
|
85
|
+
through the window, so no second `blur` fired and the second frame stayed
|
|
86
|
+
cropped under the cursor. Only a page with two cropped frames could show it.
|
|
87
|
+
|
|
7
88
|
## 0.83.0 — 2026-10-01
|
|
8
89
|
|
|
9
90
|
### Added
|
data/README.md
CHANGED
|
@@ -41,7 +41,8 @@ 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
|
|
44
|
+
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer from `config.site_footer` (brand, social profiles, link columns, an address with a live map, legal links), every part optional. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
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).
|
|
45
46
|
|
|
46
47
|
## Configuration
|
|
47
48
|
|
|
@@ -126,7 +127,7 @@ This draws the enabled auth routes (`/login`, `/signup`, `/logout`, `POST /magic
|
|
|
126
127
|
|
|
127
128
|
Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpoint (`GET /session/state`) — off by default because it inherits the host's filters, which an app should check first ([`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md)).
|
|
128
129
|
|
|
129
|
-
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 ([`docs/
|
|
130
|
+
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)).
|
|
130
131
|
|
|
131
132
|
**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`.
|
|
132
133
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
4
|
# The site footer and the booking primitives, as view helpers. See
|
|
5
|
-
# docs/SITE_FOOTER.md.
|
|
5
|
+
# docs/SITE_FOOTER.md and docs/BOOKING.md.
|
|
6
6
|
#
|
|
7
7
|
# <%= studio_site_footer %> the footer, where it should show
|
|
8
8
|
# <%= studio_booking_frame %> Google's booking page, inline
|
|
@@ -40,8 +40,8 @@ module Studio
|
|
|
40
40
|
|
|
41
41
|
# Where the footer shows: Studio.site_footer_visible, asked of this view. By
|
|
42
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
|
|
44
|
-
#
|
|
43
|
+
# the controllers in Studio.site_footer_controllers (plus the booking page).
|
|
44
|
+
# Always false for an app that declared no footer.
|
|
45
45
|
def studio_show_site_footer?
|
|
46
46
|
return false if studio_site_footer_facts.nil?
|
|
47
47
|
|
|
@@ -75,12 +75,11 @@ module Studio
|
|
|
75
75
|
def studio_booking_embed_url = Studio::Booking.embed_url(Studio.booking_url)
|
|
76
76
|
|
|
77
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
|
-
# engine
|
|
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.
|
|
80
81
|
def studio_booking_fallback_path
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
Studio.booking_url
|
|
82
|
+
Studio.booking_path_for(self) || Studio.booking_url
|
|
84
83
|
end
|
|
85
84
|
|
|
86
85
|
# The frame's accessible name.
|
|
@@ -99,11 +98,20 @@ module Studio
|
|
|
99
98
|
studio_site_footer_facts&.dig(:name) || Studio.site_identity[:title]
|
|
100
99
|
end
|
|
101
100
|
|
|
102
|
-
# The
|
|
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.
|
|
103
111
|
def studio_booking_frame(title: nil, crop: true)
|
|
104
112
|
return unless studio_booking?
|
|
105
113
|
|
|
106
|
-
render "studio/booking/frame", title: studio_booking_title(title), crop: crop
|
|
114
|
+
render "studio/booking/frame", title: studio_booking_title(title), crop: studio_booking_crop(crop)
|
|
107
115
|
end
|
|
108
116
|
|
|
109
117
|
# The booking dialog, once per page however often it is asked for. The
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
/* A booking link on this page scrolls here; land below the pinned navbar. */
|
|
11
11
|
scroll-margin-top: calc(var(--nav-bottom, 4rem) + 1rem); }
|
|
12
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. */
|
|
13
17
|
@media (min-width: 640px) {
|
|
14
|
-
.booking-frame iframe { height: 732px; }
|
|
15
|
-
.booking-frame-cropped { height:
|
|
16
|
-
.booking-frame-cropped iframe { margin-top: -
|
|
17
|
-
.booking-frame-cropped.is-open { height: 732px; }
|
|
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); }
|
|
18
22
|
.booking-frame-cropped.is-open iframe { margin-top: 0; }
|
|
19
23
|
}
|
|
20
24
|
/* Scripts off: the frame never gets its src, so it is hidden and a plain link
|
|
21
25
|
stands in its place (the <noscript> in studio/booking/_frame). */
|
|
22
26
|
.booking-frame-noscript { margin: 0; padding: 3rem 1.5rem; text-align: center; color: #111; }
|
|
23
27
|
.booking-frame-noscript a { color: #4338ca; font-weight: 700; text-decoration: underline; }
|
|
24
|
-
.booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; color: var(--color-text-muted, inherit); }
|
|
28
|
+
.booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; line-height: 1.25rem; color: var(--color-text-muted, inherit); }
|
|
25
29
|
.booking-frame-note a { color: var(--color-primary, #8b5cf6); }
|
|
26
30
|
.booking-frame-note a:hover { text-decoration: underline; }
|
|
27
31
|
|
|
@@ -69,15 +73,23 @@
|
|
|
69
73
|
if (window.__studioBookingFramesArmed) return;
|
|
70
74
|
window.__studioBookingFramesArmed = true;
|
|
71
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
|
+
|
|
72
84
|
function arm() {
|
|
73
85
|
document.querySelectorAll('iframe[data-booking-frame][data-studio-booking][data-src]').forEach(function (frame) {
|
|
74
86
|
if (frame.__bookingArmed) return;
|
|
75
87
|
frame.__bookingArmed = true;
|
|
76
|
-
if (typeof IntersectionObserver !== 'function') { frame
|
|
88
|
+
if (typeof IntersectionObserver !== 'function') { load(frame); return; }
|
|
77
89
|
var watcher = new IntersectionObserver(function (entries) {
|
|
78
90
|
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
79
91
|
watcher.disconnect();
|
|
80
|
-
frame
|
|
92
|
+
load(frame);
|
|
81
93
|
}, { rootMargin: '200px' });
|
|
82
94
|
watcher.observe(frame);
|
|
83
95
|
});
|
|
@@ -90,12 +102,34 @@
|
|
|
90
102
|
|
|
91
103
|
// Focus moving into the frame is the one signal a cross-origin frame gives
|
|
92
104
|
// its parent. It means the visitor has started using the calendar.
|
|
93
|
-
|
|
105
|
+
function openActive() {
|
|
94
106
|
var active = document.activeElement;
|
|
95
107
|
if (!active || !active.matches || !active.matches('iframe[data-booking-frame][data-studio-booking]')) return;
|
|
96
108
|
var wrap = active.closest('[data-booking-wrap][data-studio-booking]');
|
|
97
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);
|
|
98
131
|
});
|
|
132
|
+
window.addEventListener('focus', stopWatching);
|
|
99
133
|
|
|
100
134
|
document.addEventListener('turbo:load', armAfterLoad);
|
|
101
135
|
armAfterLoad();
|
|
@@ -127,7 +161,7 @@
|
|
|
127
161
|
var wrap = inline.closest('[data-booking-wrap][data-studio-booking]');
|
|
128
162
|
if (wrap) wrap.classList.add('is-open');
|
|
129
163
|
// Before `load` the frame keeps waiting: its own script assigns src then.
|
|
130
|
-
if (
|
|
164
|
+
if (document.readyState === 'complete' && window.__studioBookingLoad) window.__studioBookingLoad(inline);
|
|
131
165
|
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
132
166
|
(wrap || inline).scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' });
|
|
133
167
|
inline.focus({ preventScroll: true });
|
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
width: month beside the slots from about 600px, stacked below that. It
|
|
5
5
|
cannot be themed, so it sits on a white panel in both themes.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
13
18
|
button. A cross-origin frame reports no clicks, but the parent window does
|
|
14
19
|
lose focus to it; on that signal the wrapper opens to the full frame.
|
|
15
20
|
Below 640px Google stacks the month above the slots and nothing is cropped.
|
|
16
|
-
Those numbers are Google's layout, measured 2026-09-30; `crop: false` drops
|
|
17
|
-
the crop if that layout moves.
|
|
18
21
|
|
|
19
22
|
THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
|
|
20
23
|
party, and a frame that starts loading before the window's `load` event
|
|
@@ -25,7 +28,7 @@
|
|
|
25
28
|
WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
|
|
26
29
|
empty frame and puts a plain link to Google's booking page in its place. %>
|
|
27
30
|
<%= studio_booking_assets %>
|
|
28
|
-
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking
|
|
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 %>>
|
|
29
32
|
<iframe data-booking-frame data-studio-booking
|
|
30
33
|
data-src="<%= studio_booking_embed_url %>"
|
|
31
34
|
title="<%= title %>"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<%# /schedule — the booking page (Studio::BookingsController), drawn by
|
|
2
2
|
Studio.draw_booking_routes. It is where a booking link lands when the popup
|
|
3
|
-
cannot open.
|
|
4
|
-
|
|
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. %>
|
|
5
7
|
<% label = studio_booking_label %>
|
|
6
8
|
<% content_for(:title, "#{label} · #{studio_site_footer_name}") %>
|
|
7
9
|
<% content_for(:full_width, true) %>
|
|
@@ -11,20 +11,43 @@
|
|
|
11
11
|
border-top: 1px solid var(--ftr-line); }
|
|
12
12
|
.ftr a { transition: color .15s ease, opacity .15s ease; }
|
|
13
13
|
.ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
|
|
14
|
-
.ftr-link { color: inherit; opacity: .78; text-decoration: none;
|
|
14
|
+
.ftr-link { color: inherit; opacity: .78; text-decoration: none; }
|
|
15
|
+
/* A label that is an address (an email address, a URL) stays on one line: a browser
|
|
16
|
+
would otherwise wrap it after a hyphen or a slash. Its track is at least as
|
|
17
|
+
wide as it is (the grid below), and the tracks beside it give way. It is
|
|
18
|
+
broken only on a screen narrower than any phone, where no layout holds it. */
|
|
19
|
+
.ftr-link-solid { white-space: nowrap; }
|
|
20
|
+
@media (max-width: 299px) { .ftr-link-solid { white-space: normal; overflow-wrap: anywhere; } }
|
|
15
21
|
.ftr-link-disabled { opacity: .38; cursor: not-allowed; }
|
|
16
22
|
.ftr-link-plain { opacity: .78; }
|
|
17
23
|
.ftr-link:hover, .ftr-link:focus-visible { opacity: 1; color: var(--ftr-primary); }
|
|
18
24
|
|
|
19
|
-
/* Brand, then the link columns.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
/* Brand, then the link columns (.ftr-col), on a grid with explicit tracks.
|
|
26
|
+
below 768px: the brand across the top, the link columns two to a row
|
|
27
|
+
from 768px: the brand across the top, every link column in one row
|
|
28
|
+
from 1024px: the brand in that row too, at 1.7 shares
|
|
29
|
+
A track is `Nfr`, never `minmax(0, Nfr)`: an fr track is at least as wide as
|
|
30
|
+
its longest unbreakable word, so the column holding an email address grows
|
|
31
|
+
to hold it and its neighbours give way. Nothing moves to another row.
|
|
32
|
+
--ftr-tracks is one track per link column (Studio::SiteFooter.tracks): the
|
|
33
|
+
first is wider by default, and a column's `width:` hint sets its own.
|
|
34
|
+
A footer with more than four link columns (.ftr-cols-many) keeps the brand
|
|
35
|
+
across the top and wraps the columns four to a row. */
|
|
36
|
+
.ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: 1.2fr 1fr; }
|
|
23
37
|
.ftr-brand { grid-column: 1 / -1; }
|
|
24
|
-
|
|
38
|
+
/* The smallest phones (320px): two columns still share a row beside an email
|
|
39
|
+
address, by a narrower gutter and a slightly smaller address. */
|
|
40
|
+
@media (max-width: 359px) {
|
|
41
|
+
.ftr-cols { column-gap: 1rem; }
|
|
42
|
+
.ftr-col .ftr-link-solid { font-size: .875rem; }
|
|
43
|
+
}
|
|
44
|
+
@media (min-width: 768px) {
|
|
45
|
+
.ftr-cols { grid-template-columns: var(--ftr-tracks, 1fr); }
|
|
46
|
+
.ftr-cols-many { grid-template-columns: repeat(4, 1fr); }
|
|
47
|
+
}
|
|
25
48
|
@media (min-width: 1024px) {
|
|
26
|
-
.ftr-cols { grid-template-columns:
|
|
27
|
-
.ftr-brand { grid-column: auto; }
|
|
49
|
+
.ftr-cols:not(.ftr-cols-many) { grid-template-columns: 1.7fr var(--ftr-tracks,); }
|
|
50
|
+
.ftr-cols:not(.ftr-cols-many) .ftr-brand { grid-column: auto; }
|
|
28
51
|
}
|
|
29
52
|
.ftr-home { display: inline-flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
|
|
30
53
|
.ftr-logo { width: 3rem; height: 3rem; object-fit: contain; }
|
|
@@ -47,9 +70,9 @@
|
|
|
47
70
|
.ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
|
48
71
|
|
|
49
72
|
.ftr-location { border-top: 1px solid var(--ftr-line); padding-block: 3.5rem 2rem; text-align: center; }
|
|
50
|
-
.ftr-location-title { margin: 0; font-size: 1.875rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
|
|
73
|
+
.ftr-location-title { margin: 0; font-size: 1.875rem; line-height: 2.25rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
|
|
51
74
|
.ftr-address { margin-top: 1rem; font-style: normal; line-height: 1.625; }
|
|
52
|
-
.ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; }
|
|
75
|
+
.ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; line-height: 1.25rem; }
|
|
53
76
|
.ftr-legal p { margin: 0; }
|
|
54
77
|
.ftr-legal .ftr-copyright { margin-top: .5rem; color: var(--color-text-muted, inherit); }
|
|
55
78
|
/* With no map between them, a rule separates the legal line from what is above. */
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
<%= studio_site_footer_assets %>
|
|
14
14
|
<footer class="ftr" data-site-footer>
|
|
15
15
|
<div class="ftr-wrap">
|
|
16
|
-
|
|
16
|
+
<% tracks = Studio::SiteFooter.tracks(columns) %>
|
|
17
|
+
<div class="ftr-cols<%= " ftr-cols-many" if columns.size > Studio::SiteFooter::MAX_ROW_COLUMNS %>"<% if tracks %> style="--ftr-tracks: <%= tracks %>"<% end %>>
|
|
17
18
|
<div class="ftr-brand">
|
|
18
19
|
<% if facts[:logo] || facts[:wordmark] %>
|
|
19
20
|
<a href="<%= facts[:home_path] %>" class="ftr-home" aria-label="<%= facts[:name] %>">
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
<p class="ftr-tagline"><%= facts[:tagline] %></p>
|
|
30
31
|
<% end %>
|
|
31
32
|
<% if facts[:email] %>
|
|
32
|
-
<p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link"><%= facts[:email] %></a></p>
|
|
33
|
+
<p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
|
|
33
34
|
<% end %>
|
|
34
35
|
<% if facts[:social].any? %>
|
|
35
36
|
<ul class="ftr-socials" aria-label="Social profiles">
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
</div>
|
|
49
50
|
|
|
50
51
|
<% columns.each do |column| %>
|
|
51
|
-
<nav aria-label="<%= column[:heading] || "Footer" %>">
|
|
52
|
+
<nav class="ftr-col" aria-label="<%= column[:heading] || "Footer" %>">
|
|
52
53
|
<% if column[:heading] %>
|
|
53
54
|
<h2 class="ftr-heading"><%= column[:heading] %></h2>
|
|
54
55
|
<% end %>
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
<% elsif link[:disabled] %>
|
|
9
9
|
<span class="ftr-link-disabled" aria-disabled="true" title="Coming soon"><%= link[:label] %></span>
|
|
10
10
|
<% else %>
|
|
11
|
-
|
|
11
|
+
<%# An email address or a URL as a label is one word however many hyphens and
|
|
12
|
+
slashes it holds: .ftr-link-solid keeps it on one line. %>
|
|
13
|
+
<%= link_to link[:label], link[:href], { class: Studio::SiteFooter.solid_label?(link[:label]) ? "ftr-link ftr-link-solid" : "ftr-link" }
|
|
12
14
|
.merge(link[:external] ? { target: "_blank", rel: "noopener" } : {})
|
|
13
15
|
.merge(link[:booking] && booking ? { data: { booking_popup: true, studio_booking: true } } : {}) %>
|
|
14
16
|
<% end %>
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
<%# The Site footer and booking group of the Tricks board (admin/style).
|
|
2
2
|
The primitives a public site ends with: the footer, its map, and the Google
|
|
3
|
-
Calendar booking frame, popup and link (docs/SITE_FOOTER.md
|
|
3
|
+
Calendar booking frame, popup and link (docs/SITE_FOOTER.md,
|
|
4
|
+
docs/BOOKING.md). Each card
|
|
4
5
|
gives the helper to call; the preview below them is the real footer partial,
|
|
5
6
|
rendered with THIS app's facts when it has declared any and with a sample
|
|
6
7
|
when it has not, so the group is never empty.
|
|
7
8
|
|
|
8
|
-
The booking frame is
|
|
9
|
-
Google for a calendar every time an admin scrolls past.
|
|
9
|
+
The booking frame is DRAWN, not embedded: a style guide should not ask
|
|
10
|
+
Google for a calendar every time an admin scrolls past. Its two states, whole
|
|
11
|
+
and cropped, are quarter-scale drawings of a made-up schedule (a 720px page
|
|
12
|
+
whose box runs from 200 to 600), cropped by the same arithmetic the real
|
|
13
|
+
wrapper uses (Studio::Booking.crop). No real schedule's numbers are here. %>
|
|
10
14
|
<%
|
|
11
15
|
own_facts = studio_site_footer_facts
|
|
12
16
|
sample = {
|
|
@@ -20,6 +24,12 @@
|
|
|
20
24
|
}
|
|
21
25
|
preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
|
|
22
26
|
booking = studio_booking?
|
|
27
|
+
own_crop = studio_booking_crop
|
|
28
|
+
sample_crop = Studio::Booking.crop(top: 200, bottom: 600, frame_height: 720)
|
|
29
|
+
scale = 0.25
|
|
30
|
+
# The made-up page, top to bottom: [label, from, to, is it the box?].
|
|
31
|
+
sample_page = [ [ "Google's header", 0, 200, false ], [ "Select an appointment time", 200, 600, true ],
|
|
32
|
+
[ "Google's credit lines", 600, 720, false ] ]
|
|
23
33
|
%>
|
|
24
34
|
<section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
25
35
|
<div class="space-y-1">
|
|
@@ -34,7 +44,8 @@
|
|
|
34
44
|
This app:
|
|
35
45
|
footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> ·
|
|
36
46
|
booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> ·
|
|
37
|
-
booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong>
|
|
47
|
+
booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong> ·
|
|
48
|
+
frame <strong class="text-body"><%= own_crop ? "cropped at rest" : "whole at rest" %></strong>
|
|
38
49
|
</p>
|
|
39
50
|
</div>
|
|
40
51
|
|
|
@@ -59,14 +70,26 @@
|
|
|
59
70
|
<% end %>
|
|
60
71
|
<% end %>
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
<% [ [ "studio_booking_frame", nil, "whole",
|
|
74
|
+
%(studio_booking_frame # whole at rest: the default, and crop: false),
|
|
75
|
+
"Whole at rest, the default. Google's page as Google draws it: its header, the slot picker, its credit lines." ],
|
|
76
|
+
[ "config.booking_crop", sample_crop, "cropped",
|
|
77
|
+
%(config.booking_crop = { top: 200, bottom: 600, frame_height: 720 } # bin/booking-crop-measure prints yours),
|
|
78
|
+
"Cropped at rest to the slot picker, from numbers measured on the app's own schedule. Opens to the whole page when it is used." ]
|
|
79
|
+
].each do |klass, crop, state, usage, caption| %>
|
|
80
|
+
<%= render layout: "style/specimen",
|
|
81
|
+
locals: { klass: klass, disabled: !booking, disabled_label: "no booking_url", usage: usage } do %>
|
|
82
|
+
<div class="w-full space-y-3" data-booking-specimen="<%= state %>">
|
|
83
|
+
<div style="margin-inline: auto; width: 11rem; overflow: hidden; background: #fff; border-radius: .5rem; border: 1px solid rgb(127 127 127 / .35); height: <%= ((crop ? crop[:window] : 720) * scale).round %>px;">
|
|
84
|
+
<div style="margin-top: -<%= ((crop ? crop[:offset] : 0) * scale).round %>px;">
|
|
85
|
+
<% sample_page.each do |label, from, to, box| %>
|
|
86
|
+
<div style="box-sizing: border-box; height: <%= ((to - from) * scale).round %>px; display: flex; align-items: center; justify-content: center; padding: 0 .5rem; text-align: center; font-size: .625rem; line-height: 1.2; color: #374151;<%= box ? " margin: 0 .35rem; border: 1px solid rgb(17 24 39 / .45); border-radius: .35rem; font-weight: 700;" : " background: rgb(17 24 39 / .06);" %>"><%= label %></div>
|
|
87
|
+
<% end %>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<p class="text-sm text-secondary text-center"><%= caption %></p>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
70
93
|
<% end %>
|
|
71
94
|
</div>
|
|
72
95
|
|
data/lib/studio/booking.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Studio.booking_url — the app's Google Calendar appointment schedule —
|
|
4
|
-
#
|
|
5
|
-
# docs/
|
|
3
|
+
# Studio.booking_url — the app's Google Calendar appointment schedule — the two
|
|
4
|
+
# URLs the booking primitives derive from it, and the frame's crop
|
|
5
|
+
# (Studio.booking_crop). Pure Ruby. See docs/BOOKING.md.
|
|
6
6
|
#
|
|
7
7
|
# The configured value is the schedule's PUBLIC page, the URL Google gives you
|
|
8
8
|
# to share, without `?gv=true`:
|
|
@@ -15,15 +15,175 @@ module Studio
|
|
|
15
15
|
module Booking
|
|
16
16
|
EMBED_PARAM = "gv=true"
|
|
17
17
|
|
|
18
|
+
# THE CROP. At rest the inline frame can show only Google's "Select an
|
|
19
|
+
# appointment time" box, with Google's header above it and its credit lines
|
|
20
|
+
# below clipped away. Where that box sits is a fact about ONE schedule (its
|
|
21
|
+
# header is as tall as its title, description and meeting line; its box is as
|
|
22
|
+
# tall as its fullest day), so the app measures its own and declares it:
|
|
23
|
+
#
|
|
24
|
+
# config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
|
|
25
|
+
#
|
|
26
|
+
# All three are pixels in Google's own page, at 640px wide or more:
|
|
27
|
+
#
|
|
28
|
+
# top: the box's top edge. Stable for a schedule.
|
|
29
|
+
# bottom: the box's bottom edge ON ITS FULLEST DAY. Not stable: the box
|
|
30
|
+
# grows a row per appointment slot, so it is shorter on a day
|
|
31
|
+
# with fewer slots left. Declare the tallest it gets.
|
|
32
|
+
# frame_height: the whole page's height on that fullest day. The frame is
|
|
33
|
+
# this tall once it opens.
|
|
34
|
+
#
|
|
35
|
+
# `bin/booking-crop-measure <booking url>` prints the Hash.
|
|
36
|
+
#
|
|
37
|
+
# The window is [top - CROP_MARGIN, bottom + CROP_MARGIN], held inside the
|
|
38
|
+
# frame. Because `bottom` is the tallest the box gets, a day with fewer slots
|
|
39
|
+
# shows a strip of Google's credit lines under a shorter box; it never cuts
|
|
40
|
+
# the box. A value that cannot be a crop (not a number, negative, bottom not
|
|
41
|
+
# below top, a box that ends outside the frame, a window that hides nothing)
|
|
42
|
+
# is reported once and the frame shows whole.
|
|
43
|
+
CROP_MARGIN = 6
|
|
44
|
+
CROP_KEYS = %i[top bottom frame_height].freeze
|
|
45
|
+
REPORTED_LIMIT = 100
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Where a refused crop is reported: a callable taking the message. The
|
|
49
|
+
# default writes one warning to the Rails log (or to stderr without Rails).
|
|
50
|
+
attr_writer :reporter
|
|
51
|
+
|
|
52
|
+
def reporter
|
|
53
|
+
@reporter ||= lambda do |message|
|
|
54
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
55
|
+
::Rails.logger.warn(message)
|
|
56
|
+
else
|
|
57
|
+
warn(message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reported
|
|
63
|
+
@reported ||= {}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
18
67
|
module_function
|
|
19
68
|
|
|
69
|
+
def validate_crop!(declared)
|
|
70
|
+
return if declared.nil? || declared == false || declared.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"Studio.booking_crop must be nil, false, or a Hash of top:, bottom: and frame_height: " \
|
|
74
|
+
"(got #{declared.inspect}). See docs/BOOKING.md."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The crop to render: { offset:, window:, frame_height: } in whole pixels, or
|
|
78
|
+
# nil for the whole frame at rest. `offset` is how far the frame is pulled up
|
|
79
|
+
# behind its wrapper and `window` is the wrapper's height.
|
|
80
|
+
def crop(declared)
|
|
81
|
+
return nil if declared.nil? || declared == false
|
|
82
|
+
return refuse_crop(declared, "it is not a Hash") unless declared.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
values = CROP_KEYS.to_h { |key| [key, declared[key] || declared[key.to_s]] }
|
|
85
|
+
missing = values.reject { |_key, value| value.is_a?(Numeric) && value.respond_to?(:round) && value.finite? }.keys
|
|
86
|
+
return refuse_crop(declared, "#{missing.join(', ')} must be a number") unless missing.empty?
|
|
87
|
+
|
|
88
|
+
top, bottom, frame_height = values.values_at(*CROP_KEYS).map(&:round)
|
|
89
|
+
return refuse_crop(declared, "top is negative") if top.negative?
|
|
90
|
+
return refuse_crop(declared, "bottom is not below top") unless bottom > top
|
|
91
|
+
return refuse_crop(declared, "bottom is outside the frame") if bottom > frame_height
|
|
92
|
+
|
|
93
|
+
offset = [top - CROP_MARGIN, 0].max
|
|
94
|
+
window = [bottom + CROP_MARGIN, frame_height].min - offset
|
|
95
|
+
return refuse_crop(declared, "the window is the whole frame") unless window < frame_height
|
|
96
|
+
|
|
97
|
+
{ offset: offset, window: window, frame_height: frame_height }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The crop as the custom properties the wrapper carries, so two frames with
|
|
101
|
+
# different crops can share a page (studio/booking/_assets reads them).
|
|
102
|
+
def crop_style(crop)
|
|
103
|
+
return nil if crop.nil?
|
|
104
|
+
|
|
105
|
+
"--booking-crop-offset: #{crop[:offset]}px; --booking-crop-window: #{crop[:window]}px; " \
|
|
106
|
+
"--booking-frame-height: #{crop[:frame_height]}px"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refuse_crop(declared, why)
|
|
110
|
+
report_once("[studio.booking] booking crop #{declared.inspect} is ignored (#{why}); the frame shows whole. " \
|
|
111
|
+
"See docs/BOOKING.md.")
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A LOCAL PATH, and nothing else, may be the booking page: it is written into
|
|
116
|
+
# hrefs (the footer's booking links, `studio_booking_link`). It begins with
|
|
117
|
+
# one "/" and carries no control character. That refuses a scheme
|
|
118
|
+
# ("javascript:", "https:") and a protocol-relative "//host", which a browser
|
|
119
|
+
# reads as another site.
|
|
120
|
+
def local_path?(path)
|
|
121
|
+
string = path.to_s
|
|
122
|
+
string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Studio.booking_path: nil, a local path, or a callable receiving the view.
|
|
126
|
+
def validate_path!(declared)
|
|
127
|
+
return if declared.nil? || declared.respond_to?(:call)
|
|
128
|
+
return if declared.is_a?(String) && (declared.strip.empty? || local_path?(declared.strip))
|
|
129
|
+
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"Studio.booking_path must be nil, a path beginning with one \"/\", or a callable receiving the view " \
|
|
132
|
+
"(got #{declared.inspect}). See docs/BOOKING.md."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The declared value as it is stored: a stripped path, a callable, or nil.
|
|
136
|
+
def normalize_path(declared)
|
|
137
|
+
validate_path!(declared)
|
|
138
|
+
return declared unless declared.is_a?(String)
|
|
139
|
+
|
|
140
|
+
declared.strip.empty? ? nil : declared.strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The booking page's path for a view: the app's own (`declared`, a path or
|
|
144
|
+
# a callable receiving the view), else the engine's /schedule when it is
|
|
145
|
+
# `drawn`, else nil. A callable may decline with nil. What a callable returns
|
|
146
|
+
# is held to the same rule as a declared String: anything but a local path is
|
|
147
|
+
# reported once and treated as no answer.
|
|
148
|
+
def path_for(declared, view, drawn: false)
|
|
149
|
+
unless declared.nil?
|
|
150
|
+
path = (declared.respond_to?(:call) ? declared.call(view) : declared).to_s.strip
|
|
151
|
+
return path if local_path?(path)
|
|
152
|
+
|
|
153
|
+
refuse_path(path) unless path.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
drawn && view.respond_to?(:studio_booking_path) ? view.studio_booking_path : nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def refuse_path(path)
|
|
160
|
+
report_once("[studio.booking] booking_path #{path.inspect} is ignored: it is not a local path " \
|
|
161
|
+
"(one leading \"/\"). See docs/BOOKING.md.")
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def report_once(message)
|
|
166
|
+
seen = Studio::Booking.reported
|
|
167
|
+
return if seen.key?(message)
|
|
168
|
+
|
|
169
|
+
seen[message] = true if seen.size < REPORTED_LIMIT
|
|
170
|
+
Studio::Booking.reporter.call(message)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# True when `current` (a request path) is the page `declared` names. A query
|
|
174
|
+
# string, a fragment and a trailing slash on either side do not count.
|
|
175
|
+
def same_path?(current, declared)
|
|
176
|
+
a, b = [current, declared].map { |path| path.to_s.strip.sub(/[?#].*\z/m, "").sub(%r{(?<=.)/+\z}, "") }
|
|
177
|
+
!a.empty? && a == b
|
|
178
|
+
end
|
|
179
|
+
|
|
20
180
|
def validate!(url)
|
|
21
181
|
return if url.nil? || url.to_s.strip.empty?
|
|
22
182
|
return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
|
|
23
183
|
|
|
24
184
|
raise ArgumentError,
|
|
25
185
|
"Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
|
|
26
|
-
"schedule's public link. See docs/
|
|
186
|
+
"schedule's public link. See docs/BOOKING.md."
|
|
27
187
|
end
|
|
28
188
|
|
|
29
189
|
# The public page, with any embed parameter removed. nil when unset.
|
data/lib/studio/site_footer.rb
CHANGED
|
@@ -94,6 +94,16 @@ module Studio
|
|
|
94
94
|
nil
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# True on the app's OWN booking page (Studio.booking_path), which keeps the
|
|
98
|
+
# footer for a signed-in viewer as the engine's page does. Matched by the
|
|
99
|
+
# request's path, because the app's controller could be named anything.
|
|
100
|
+
def booking_page?(view, booking_path)
|
|
101
|
+
return false if booking_path.nil?
|
|
102
|
+
return false unless view.respond_to?(:request) && view.request.respond_to?(:path)
|
|
103
|
+
|
|
104
|
+
Studio::Booking.same_path?(view.request.path, booking_path)
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
def validate!(declared)
|
|
98
108
|
return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
|
|
99
109
|
|
|
@@ -106,7 +116,7 @@ module Studio
|
|
|
106
116
|
#
|
|
107
117
|
# name: the site name the engine resolved (the site identity's title)
|
|
108
118
|
# logo: the logo the engine resolved (the navbar logo), or nil
|
|
109
|
-
# booking_path: the booking page's path
|
|
119
|
+
# booking_path: the booking page's path (Studio.booking_path_for), or nil
|
|
110
120
|
def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
|
|
111
121
|
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
112
122
|
return nil if raw.nil?
|
|
@@ -149,8 +159,16 @@ module Studio
|
|
|
149
159
|
# config.site_footer_visible = ->(view) {
|
|
150
160
|
# Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("app/")
|
|
151
161
|
# }
|
|
152
|
-
|
|
162
|
+
#
|
|
163
|
+
# `booking_path` is the app's own booking page (Studio.booking_path, resolved
|
|
164
|
+
# for this view); the engine's page is exempt by its controller instead.
|
|
165
|
+
def default_visible?(view, controllers: nil, booking_path: nil)
|
|
153
166
|
controllers = Studio.site_footer_controllers if controllers.nil? && defined?(Studio.site_footer_controllers)
|
|
167
|
+
if booking_path.nil? && Studio.respond_to?(:booking_path) && Studio.booking_path
|
|
168
|
+
booking_path = Studio.booking_path_for(view)
|
|
169
|
+
end
|
|
170
|
+
return true if booking_page?(view, booking_path)
|
|
171
|
+
|
|
154
172
|
visible?(
|
|
155
173
|
logged_in: view.respond_to?(:logged_in?) && view.logged_in? ? true : false,
|
|
156
174
|
controller_name: view.respond_to?(:controller_name) ? view.controller_name : nil,
|
|
@@ -235,12 +253,14 @@ module Studio
|
|
|
235
253
|
unlinked: !text(url).nil? && safe_href(url, "social url").nil? }
|
|
236
254
|
end
|
|
237
255
|
|
|
238
|
-
# [ heading, links ]
|
|
256
|
+
# [ heading, links ], [ heading, links, { width: 1.5 } ] or { heading:,
|
|
257
|
+
# links:, width: }. `width` is an optional hint: this column's share of the
|
|
258
|
+
# row against the others' 1 (see `tracks`).
|
|
239
259
|
def column(row, booking_path)
|
|
240
|
-
heading, links =
|
|
260
|
+
heading, links, options =
|
|
241
261
|
if row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
242
262
|
hash = symbolize(row)
|
|
243
|
-
[hash[:heading] || hash[:title], hash[:links]]
|
|
263
|
+
[hash[:heading] || hash[:title], hash[:links], hash]
|
|
244
264
|
else
|
|
245
265
|
Array(row)
|
|
246
266
|
end
|
|
@@ -248,12 +268,48 @@ module Studio
|
|
|
248
268
|
links = Array(links).filter_map { |link_row| link(link_row, booking_path) }
|
|
249
269
|
return nil if heading.nil? && links.empty?
|
|
250
270
|
|
|
251
|
-
|
|
271
|
+
options = options.respond_to?(:to_h) && !options.is_a?(Array) ? symbolize(options) : {}
|
|
272
|
+
{ heading: heading, links: links, width: column_width(options[:width]) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A column's width hint, or nil for the default share. Anything that is not a
|
|
276
|
+
# number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
|
|
277
|
+
MIN_COLUMN_WIDTH = 0.5
|
|
278
|
+
MAX_COLUMN_WIDTH = 4
|
|
279
|
+
|
|
280
|
+
def column_width(value)
|
|
281
|
+
return nil unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
|
|
282
|
+
return nil unless value >= MIN_COLUMN_WIDTH && value <= MAX_COLUMN_WIDTH
|
|
283
|
+
|
|
284
|
+
value.to_f
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# True for a label that is an address rather than words: no space in it, and
|
|
288
|
+
# an "@", a "." or a "/". It is kept on one line (.ftr-link-solid).
|
|
289
|
+
def solid_label?(label)
|
|
290
|
+
string = label.to_s
|
|
291
|
+
!string.match?(/\s/) && string.match?(%r{[@./]})
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# THE LINK COLUMNS' GRID TRACKS from 768px, one per column: its `width:`
|
|
295
|
+
# hint as an fr share, else FIRST_COLUMN_WIDTH for the first (the one that
|
|
296
|
+
# usually holds an email address) and 1 for the rest. nil when there are no
|
|
297
|
+
# columns, or more than MAX_ROW_COLUMNS: those wrap as equal tracks.
|
|
298
|
+
FIRST_COLUMN_WIDTH = 1.5
|
|
299
|
+
MAX_ROW_COLUMNS = 4
|
|
300
|
+
|
|
301
|
+
def tracks(columns)
|
|
302
|
+
columns = Array(columns)
|
|
303
|
+
return nil if columns.empty? || columns.size > MAX_ROW_COLUMNS
|
|
304
|
+
|
|
305
|
+
columns.each_with_index.map do |column, index|
|
|
306
|
+
"#{format('%g', column[:width] || (index.zero? ? FIRST_COLUMN_WIDTH : 1))}fr"
|
|
307
|
+
end.join(" ")
|
|
252
308
|
end
|
|
253
309
|
|
|
254
310
|
# [ label, href ], [ label, href, { booking: true } ] or { label:, href:,
|
|
255
311
|
# booking: }. A nil href is a disabled label; an http(s) href opens in a new
|
|
256
|
-
# tab; `booking: true`, or an href equal to the
|
|
312
|
+
# tab; `booking: true`, or an href equal to the booking page's path, opens
|
|
257
313
|
# the booking popup (the href stays as the fallback).
|
|
258
314
|
def link(row, booking_path = nil)
|
|
259
315
|
label, href, options =
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -565,7 +565,7 @@ module Studio
|
|
|
565
565
|
# The public site's footer: brand and social profiles, link columns, an
|
|
566
566
|
# optional Location band with a live map, and a legal line. And the booking
|
|
567
567
|
# primitives: Google Calendar's appointment page inline, in a popup, and on a
|
|
568
|
-
# page. See docs/SITE_FOOTER.md.
|
|
568
|
+
# page. See docs/SITE_FOOTER.md and docs/BOOKING.md.
|
|
569
569
|
|
|
570
570
|
# The footer's facts: a Hash, or a callable receiving the view. nil (the
|
|
571
571
|
# default) means this app has no footer, and `studio_site_footer` renders
|
|
@@ -590,7 +590,8 @@ module Studio
|
|
|
590
590
|
# controller name ("landing") or path ("admin/reports"). A visitor gets the
|
|
591
591
|
# footer on every page; a signed-in viewer gets it only here, because every
|
|
592
592
|
# other signed-in page is a working surface (a board, a queue, an editor) and
|
|
593
|
-
# stays full height. The engine's
|
|
593
|
+
# stays full height. The booking page (the engine's, or the app's own at
|
|
594
|
+
# Studio.booking_path) is always included.
|
|
594
595
|
#
|
|
595
596
|
# config.site_footer_controllers = %w[landing packages contact_submissions]
|
|
596
597
|
mattr_accessor :site_footer_controllers, default: []
|
|
@@ -634,6 +635,44 @@ module Studio
|
|
|
634
635
|
# config.draw_booking_routes = true
|
|
635
636
|
mattr_accessor :draw_booking_routes, default: false
|
|
636
637
|
|
|
638
|
+
# WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
|
|
639
|
+
# `studio_booking_frame` in a view of its own instead of drawing the engine's
|
|
640
|
+
# /schedule: a path ("/schedule"), or a callable receiving the view
|
|
641
|
+
# (`->(view) { view.schedule_path }`). nil (the default) leaves the engine's
|
|
642
|
+
# page, when drawn, as the booking page. With it set, the page is treated as
|
|
643
|
+
# the engine's own is: a footer link to it opens the popup, `studio_booking_link`
|
|
644
|
+
# falls back to it, and it keeps the footer for a signed-in viewer. It wins
|
|
645
|
+
# over the engine's page when both are declared. It is written into hrefs, so
|
|
646
|
+
# it must be a local path (one leading "/"); see Studio::Booking.local_path?.
|
|
647
|
+
#
|
|
648
|
+
# config.booking_path = "/schedule"
|
|
649
|
+
mattr_reader :booking_path, default: nil
|
|
650
|
+
|
|
651
|
+
def self.booking_path=(declared)
|
|
652
|
+
@@booking_path = Booking.normalize_path(declared)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# The booking page's path for a view: Studio.booking_path, else the engine's
|
|
656
|
+
# /schedule when it is drawn, else nil.
|
|
657
|
+
def self.booking_path_for(view)
|
|
658
|
+
Booking.path_for(booking_path, view, drawn: draw_booking_routes)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
|
|
662
|
+
# page; a Hash of the schedule's measured box crops the frame to the slot
|
|
663
|
+
# picker until it is used. The numbers belong to ONE schedule, so there is no
|
|
664
|
+
# default crop: measure with `bin/booking-crop-measure <booking url>` and paste
|
|
665
|
+
# what it prints. Keys and derivation: lib/studio/booking.rb.
|
|
666
|
+
#
|
|
667
|
+
# config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
|
|
668
|
+
mattr_reader :booking_crop, default: nil
|
|
669
|
+
|
|
670
|
+
def self.booking_crop=(declared)
|
|
671
|
+
Booking.validate_crop!(declared)
|
|
672
|
+
Booking.crop(declared) # reports a crop that cannot be one, at boot
|
|
673
|
+
@@booking_crop = declared || nil
|
|
674
|
+
end
|
|
675
|
+
|
|
637
676
|
# THE APP'S IDENTITY COPY, resolved: { title:, description:, image_url: }.
|
|
638
677
|
# The operator's saved value (Studio::SiteIdentity, edited at
|
|
639
678
|
# /admin/link_preview) wins, then the drafted Studio.site_title /
|
|
@@ -1140,9 +1179,8 @@ module Studio
|
|
|
1140
1179
|
def self.site_footer_for(view)
|
|
1141
1180
|
return nil if site_footer.nil?
|
|
1142
1181
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
name: site_identity[:title], logo: logo_for("Footer Logo"), booking_path: booking_path)
|
|
1182
|
+
SiteFooter.resolve(site_footer, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
|
|
1183
|
+
booking_path: booking_path_for(view))
|
|
1146
1184
|
end
|
|
1147
1185
|
|
|
1148
1186
|
# Navbar links resolved for a view context (lib/studio/navbar_links.rb).
|