studio-engine 0.82.1 → 0.83.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 +55 -0
- data/README.md +3 -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 +180 -0
- data/app/views/studio/booking/_assets.html.erb +162 -0
- data/app/views/studio/booking/_frame.html.erb +41 -0
- data/app/views/studio/booking/_popup.html.erb +34 -0
- data/app/views/studio/bookings/show.html.erb +20 -0
- data/app/views/studio/site_footer/_assets.html.erb +209 -0
- data/app/views/studio/site_footer/_footer.html.erb +99 -0
- data/app/views/studio/site_footer/_link.html.erb +14 -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 +79 -0
- data/app/views/style/_tricks.html.erb +3 -0
- data/lib/studio/booking.rb +47 -0
- data/lib/studio/engine.rb +2 -0
- data/lib/studio/site_footer.rb +294 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +94 -0
- metadata +17 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<%# The site footer (Studio.site_footer; docs/SITE_FOOTER.md): brand and social
|
|
2
|
+
profiles at the left, plain link columns under bold headings, a centred
|
|
3
|
+
Location band with a live map, then a quiet centred legal line. Rendered by
|
|
4
|
+
`studio_site_footer`, which passes the resolved `facts`.
|
|
5
|
+
|
|
6
|
+
Every part is optional and a missing fact removes its part. The layout lives
|
|
7
|
+
in studio/site_footer/_assets as scoped CSS (.ftr-*), not Tailwind
|
|
8
|
+
utilities: a utility a consumer's build has not already seen compiles to
|
|
9
|
+
nothing, and a footer that silently loses its grid is the result. %>
|
|
10
|
+
<% address = facts[:address] %>
|
|
11
|
+
<% booking = studio_booking? %>
|
|
12
|
+
<% columns = facts[:columns] %>
|
|
13
|
+
<%= studio_site_footer_assets %>
|
|
14
|
+
<footer class="ftr" data-site-footer>
|
|
15
|
+
<div class="ftr-wrap">
|
|
16
|
+
<div class="ftr-cols" style="--ftr-n: <%= columns.size %>; --ftr-n-md: <%= [ columns.size, 4 ].min.clamp(1, 4) %>;">
|
|
17
|
+
<div class="ftr-brand">
|
|
18
|
+
<% if facts[:logo] || facts[:wordmark] %>
|
|
19
|
+
<a href="<%= facts[:home_path] %>" class="ftr-home" aria-label="<%= facts[:name] %>">
|
|
20
|
+
<% if facts[:logo] %>
|
|
21
|
+
<%= image_tag facts[:logo], class: [ "ftr-logo", ("ftr-logo-invert" if facts[:logo_invert]) ].compact.join(" "), alt: "" %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% if facts[:wordmark] %>
|
|
24
|
+
<span class="ftr-wordmark"><% if facts[:wordmark][0].present? %><span><%= facts[:wordmark][0] %></span><% end %><span class="ftr-wordmark-accent"><%= facts[:wordmark][1] %></span></span>
|
|
25
|
+
<% end %>
|
|
26
|
+
</a>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% if facts[:tagline] %>
|
|
29
|
+
<p class="ftr-tagline"><%= facts[:tagline] %></p>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% if facts[:email] %>
|
|
32
|
+
<p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link"><%= facts[:email] %></a></p>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if facts[:social].any? %>
|
|
35
|
+
<ul class="ftr-socials" aria-label="Social profiles">
|
|
36
|
+
<% facts[:social].each do |profile| %>
|
|
37
|
+
<li>
|
|
38
|
+
<%# A nil url renders the icon unlinked: the handle is not on record yet. %>
|
|
39
|
+
<%= content_tag(profile[:url] ? :a : :span,
|
|
40
|
+
render("studio/site_footer/social_icon", icon: profile[:icon], label: profile[:label]),
|
|
41
|
+
{ class: "ftr-social", title: profile[:url] || profile[:unlinked] ? profile[:label] : "#{profile[:label]} (handle needed)",
|
|
42
|
+
"aria-label": profile[:label] }
|
|
43
|
+
.merge(profile[:url] ? { href: profile[:url], target: "_blank", rel: "noopener" } : { "data-pending": true })) %>
|
|
44
|
+
</li>
|
|
45
|
+
<% end %>
|
|
46
|
+
</ul>
|
|
47
|
+
<% end %>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<% columns.each do |column| %>
|
|
51
|
+
<nav aria-label="<%= column[:heading] || "Footer" %>">
|
|
52
|
+
<% if column[:heading] %>
|
|
53
|
+
<h2 class="ftr-heading"><%= column[:heading] %></h2>
|
|
54
|
+
<% end %>
|
|
55
|
+
<ul class="ftr-list">
|
|
56
|
+
<% column[:links].each do |link| %>
|
|
57
|
+
<li><%= render "studio/site_footer/link", link: link, booking: booking %></li>
|
|
58
|
+
<% end %>
|
|
59
|
+
</ul>
|
|
60
|
+
</nav>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<% if address %>
|
|
65
|
+
<div class="ftr-location" data-footer-location>
|
|
66
|
+
<h2 class="ftr-location-title">Location</h2>
|
|
67
|
+
<address class="ftr-address">
|
|
68
|
+
<a href="<%= address[:directions_url] %>" target="_blank" rel="noopener" class="ftr-link"
|
|
69
|
+
aria-label="Get directions to <%= address[:full] %>">
|
|
70
|
+
<%= safe_join([ address[:street], address[:city_line] ].compact, tag.br) %>
|
|
71
|
+
</a>
|
|
72
|
+
</address>
|
|
73
|
+
</div>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<%# Outside the centred column on purpose: the map runs edge to edge. %>
|
|
78
|
+
<% if address && address[:map] %>
|
|
79
|
+
<%= render "studio/site_footer/map", address: address, classes: "ftr-map-wide", style: nil, zoom: nil, controls: true %>
|
|
80
|
+
<% end %>
|
|
81
|
+
|
|
82
|
+
<div class="ftr-wrap">
|
|
83
|
+
<div class="ftr-legal<%= " ftr-legal-ruled" unless address && address[:map] %>">
|
|
84
|
+
<% if facts[:legal].any? %>
|
|
85
|
+
<p>
|
|
86
|
+
<% facts[:legal].each_with_index do |link, index| %>
|
|
87
|
+
<%= " · " unless index.zero? %><%= render "studio/site_footer/link", link: link, booking: booking %>
|
|
88
|
+
<% end %>
|
|
89
|
+
</p>
|
|
90
|
+
<% end %>
|
|
91
|
+
<% if facts[:name] %>
|
|
92
|
+
<p class="ftr-copyright">© <%= Time.current.year %> <%= facts[:name] %></p>
|
|
93
|
+
<% end %>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</footer>
|
|
97
|
+
|
|
98
|
+
<%# The dialog the footer's booking links open. Nothing without a booking_url. %>
|
|
99
|
+
<%= studio_booking_popup(label: facts[:booking_label], title: facts[:booking_title]) %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# One footer link (a normalized row from Studio::SiteFooter.link). A nil href
|
|
2
|
+
is a disabled label. An off-site link opens in a new tab, so the visitor
|
|
3
|
+
keeps the page. A booking link opens the booking popup when the app has a
|
|
4
|
+
booking_url; its href stays as the fallback. %>
|
|
5
|
+
<% if link[:unlinked] %>
|
|
6
|
+
<%# Its href was refused (not a relative path, http, https, mailto or tel). %>
|
|
7
|
+
<span class="ftr-link-plain"><%= link[:label] %></span>
|
|
8
|
+
<% elsif link[:disabled] %>
|
|
9
|
+
<span class="ftr-link-disabled" aria-disabled="true" title="Coming soon"><%= link[:label] %></span>
|
|
10
|
+
<% else %>
|
|
11
|
+
<%= link_to link[:label], link[:href], { class: "ftr-link" }
|
|
12
|
+
.merge(link[:external] ? { target: "_blank", rel: "noopener" } : {})
|
|
13
|
+
.merge(link[:booking] && booking ? { data: { booking_popup: true, studio_booking: true } } : {}) %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# One live map of an address. Leaflet mounts on this element (see
|
|
2
|
+
studio/site_footer/_assets, which this partial pulls in); until it does, and
|
|
3
|
+
if it never does, the link inside is the whole map: an address that opens
|
|
4
|
+
directions. `classes` and `style` size and shape it.
|
|
5
|
+
|
|
6
|
+
Leaflet's two files ride the engine's asset pipeline (studio/leaflet.js,
|
|
7
|
+
studio/leaflet.css). Their URLs travel on the element, so the script fetches
|
|
8
|
+
them only on a page that has a map. %>
|
|
9
|
+
<%= studio_site_footer_assets %>
|
|
10
|
+
<div class="ftr-map <%= local_assigns[:classes] %>"
|
|
11
|
+
<% if local_assigns[:style].present? %>style="<%= style %>"<% end %>
|
|
12
|
+
data-footer-map
|
|
13
|
+
data-lat="<%= address[:lat] %>" data-lng="<%= address[:lng] %>"
|
|
14
|
+
data-zoom="<%= local_assigns[:zoom] || address[:zoom] %>"
|
|
15
|
+
data-controls="<%= local_assigns.fetch(:controls, true) %>"
|
|
16
|
+
data-leaflet-js="<%= asset_path("studio/leaflet.js") %>"
|
|
17
|
+
data-leaflet-css="<%= asset_path("studio/leaflet.css") %>"
|
|
18
|
+
role="region" aria-label="Map of <%= address[:full] %>">
|
|
19
|
+
<a href="<%= address[:directions_url] %>" target="_blank" rel="noopener" class="ftr-map-fallback">
|
|
20
|
+
<%= address[:full] %> · Open in Maps
|
|
21
|
+
</a>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# Brand marks for the footer's social row, drawn in currentColor. An icon the
|
|
2
|
+
engine has no mark for falls back to the label's first letter. %>
|
|
3
|
+
<% case icon %>
|
|
4
|
+
<% when :linkedin, :instagram, :x, :facebook, :youtube %>
|
|
5
|
+
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
|
|
6
|
+
<% case icon %>
|
|
7
|
+
<% when :linkedin %>
|
|
8
|
+
<path fill="currentColor" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 1 1 0-4.125 2.062 2.062 0 0 1 0 4.125zm1.782 13.019H3.555V9h3.564v11.452z"/>
|
|
9
|
+
<% when :instagram %>
|
|
10
|
+
<rect x="3" y="3" width="18" height="18" rx="5" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
11
|
+
<circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
12
|
+
<circle cx="17.3" cy="6.7" r="1.3" fill="currentColor"/>
|
|
13
|
+
<% when :x %>
|
|
14
|
+
<path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/>
|
|
15
|
+
<% when :facebook %>
|
|
16
|
+
<path fill="currentColor" d="M22 12a10 10 0 1 0-11.56 9.88v-6.99H7.9V12h2.54V9.8c0-2.5 1.49-3.89 3.78-3.89 1.09 0 2.24.2 2.24.2v2.46h-1.26c-1.24 0-1.63.77-1.63 1.56V12h2.78l-.44 2.89h-2.34v6.99A10 10 0 0 0 22 12z"/>
|
|
17
|
+
<% when :youtube %>
|
|
18
|
+
<path fill="currentColor" d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.6 15.6V8.4l6.2 3.6-6.2 3.6z"/>
|
|
19
|
+
<% end %>
|
|
20
|
+
</svg>
|
|
21
|
+
<% else %>
|
|
22
|
+
<span class="ftr-social-letter" aria-hidden="true"><%= label.to_s[0].to_s.upcase %></span>
|
|
23
|
+
<% end %>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%# The Site footer and booking group of the Tricks board (admin/style).
|
|
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). Each card
|
|
4
|
+
gives the helper to call; the preview below them is the real footer partial,
|
|
5
|
+
rendered with THIS app's facts when it has declared any and with a sample
|
|
6
|
+
when it has not, so the group is never empty.
|
|
7
|
+
|
|
8
|
+
The booking frame is described, not embedded: a style guide should not ask
|
|
9
|
+
Google for a calendar every time an admin scrolls past. %>
|
|
10
|
+
<%
|
|
11
|
+
own_facts = studio_site_footer_facts
|
|
12
|
+
sample = {
|
|
13
|
+
name: Studio.site_identity[:title], tagline: "What this app is, in a line",
|
|
14
|
+
email: "team@example.com",
|
|
15
|
+
address: { street: "123 Example St", city_line: "Washington, DC 20024", lat: 38.8894, lng: -77.0352 },
|
|
16
|
+
social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/" ], [ "Instagram", :instagram, nil ], [ "X", :x, "https://x.com/" ] ],
|
|
17
|
+
columns: [ [ "Contact", [ [ "Schedule a call", "#site-footer", { booking: true } ], [ "Contact", "#site-footer" ] ] ],
|
|
18
|
+
[ "Company", [ [ "Home", "/" ], [ "Career", nil ], [ "Blog", "https://example.com" ] ] ] ],
|
|
19
|
+
legal: [ [ "Privacy Policy", "#site-footer" ], [ "Terms of Service", "#site-footer" ] ]
|
|
20
|
+
}
|
|
21
|
+
preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
|
|
22
|
+
booking = studio_booking?
|
|
23
|
+
%>
|
|
24
|
+
<section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
25
|
+
<div class="space-y-1">
|
|
26
|
+
<h3 class="text-xl font-bold text-heading">Site footer and booking</h3>
|
|
27
|
+
<p class="text-muted text-sm max-w-2xl">
|
|
28
|
+
One layout line renders the public site's footer from
|
|
29
|
+
<code class="font-mono text-2xs">config.site_footer</code>; every part is optional. With
|
|
30
|
+
<code class="font-mono text-2xs">config.booking_url</code> the same pages get Google Calendar's
|
|
31
|
+
booking page inline, in a popup and on <code class="font-mono text-2xs">/schedule</code>.
|
|
32
|
+
</p>
|
|
33
|
+
<p class="text-muted text-sm" data-site-footer-status>
|
|
34
|
+
This app:
|
|
35
|
+
footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> ·
|
|
36
|
+
booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> ·
|
|
37
|
+
booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong>
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
42
|
+
<%= render layout: "style/specimen",
|
|
43
|
+
locals: { klass: "studio_site_footer",
|
|
44
|
+
usage: %(studio_site_footer # one line, at the end of the layout body) } do %>
|
|
45
|
+
<p class="text-sm text-secondary text-center">
|
|
46
|
+
The footer, where it should show: every page for a visitor, listed controllers when signed in.
|
|
47
|
+
Previewed below.
|
|
48
|
+
</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<%= render layout: "style/specimen",
|
|
52
|
+
locals: { klass: "studio_booking_link",
|
|
53
|
+
disabled: !booking, disabled_label: "no booking_url",
|
|
54
|
+
usage: %(studio_booking_link "Schedule a call", class: "btn btn-primary") } do %>
|
|
55
|
+
<% if booking %>
|
|
56
|
+
<%= studio_booking_link "Schedule a call", class: "btn btn-primary" %>
|
|
57
|
+
<% else %>
|
|
58
|
+
<span class="btn btn-primary">Schedule a call</span>
|
|
59
|
+
<% end %>
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
62
|
+
<%= render layout: "style/specimen",
|
|
63
|
+
locals: { klass: "studio_booking_frame",
|
|
64
|
+
disabled: !booking, disabled_label: "no booking_url",
|
|
65
|
+
usage: %(studio_booking_frame # crop: false shows Google's whole page at rest) } do %>
|
|
66
|
+
<p class="text-sm text-secondary text-center">
|
|
67
|
+
Google's booking page inline. Asked for only after the page has loaded and the frame is near
|
|
68
|
+
the viewport; cropped to the slot picker until it is used.
|
|
69
|
+
</p>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="space-y-2">
|
|
74
|
+
<p class="label-upper"><%= own_facts ? "This app's footer" : "Sample footer (this app declares none)" %></p>
|
|
75
|
+
<div class="card overflow-hidden" data-site-footer-preview>
|
|
76
|
+
<%= render "studio/site_footer/footer", facts: preview %>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Studio.booking_url — the app's Google Calendar appointment schedule — and the
|
|
4
|
+
# two URLs the booking primitives derive from it. Pure Ruby. See
|
|
5
|
+
# docs/SITE_FOOTER.md.
|
|
6
|
+
#
|
|
7
|
+
# The configured value is the schedule's PUBLIC page, the URL Google gives you
|
|
8
|
+
# to share, without `?gv=true`:
|
|
9
|
+
#
|
|
10
|
+
# config.booking_url = "https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID"
|
|
11
|
+
#
|
|
12
|
+
# `gv=true` is what makes Google serve the embeddable page, so the frame and the
|
|
13
|
+
# popup add it; the "open the booking page" link does not.
|
|
14
|
+
module Studio
|
|
15
|
+
module Booking
|
|
16
|
+
EMBED_PARAM = "gv=true"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def validate!(url)
|
|
21
|
+
return if url.nil? || url.to_s.strip.empty?
|
|
22
|
+
return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
|
|
23
|
+
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
|
|
26
|
+
"schedule's public link. See docs/SITE_FOOTER.md."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The public page, with any embed parameter removed. nil when unset.
|
|
30
|
+
def page_url(url)
|
|
31
|
+
string = url.to_s.strip
|
|
32
|
+
return nil if string.empty?
|
|
33
|
+
|
|
34
|
+
base, query = string.split("?", 2)
|
|
35
|
+
kept = query.to_s.split("&").reject { |pair| pair.empty? || pair == EMBED_PARAM }
|
|
36
|
+
kept.empty? ? base : "#{base}?#{kept.join('&')}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The embeddable page: the public page plus gv=true. nil when unset.
|
|
40
|
+
def embed_url(url)
|
|
41
|
+
page = page_url(url)
|
|
42
|
+
return nil if page.nil?
|
|
43
|
+
|
|
44
|
+
"#{page}#{page.include?('?') ? '&' : '?'}#{EMBED_PARAM}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/studio/engine.rb
CHANGED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
# Resolves Studio.site_footer — the facts the site footer prints — for a view.
|
|
6
|
+
# Pure Ruby (no Rails dependency), so the unit suite exercises the rules without
|
|
7
|
+
# booting the dummy app. See docs/SITE_FOOTER.md.
|
|
8
|
+
#
|
|
9
|
+
# The declared value is a Hash, or a callable that receives the view (for route
|
|
10
|
+
# helpers). EVERY KEY IS OPTIONAL, and a missing key removes its part of the
|
|
11
|
+
# footer rather than printing a placeholder:
|
|
12
|
+
#
|
|
13
|
+
# config.site_footer = ->(view) {
|
|
14
|
+
# { tagline: "Everything, by example",
|
|
15
|
+
# address: { street: "123 Example St", city_line: "Washington, DC 20024",
|
|
16
|
+
# lat: 38.8894, lng: -77.0352 },
|
|
17
|
+
# email: "team@example.com",
|
|
18
|
+
# social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/in/someone/" ],
|
|
19
|
+
# [ "Instagram", :instagram, nil ] ], # nil url: unlinked
|
|
20
|
+
# columns: [ [ "Company", [ [ "Home", view.root_path ],
|
|
21
|
+
# [ "Career", nil ], # nil path: disabled
|
|
22
|
+
# [ "Blog", "https://blog.example.com" ] ] ] ],
|
|
23
|
+
# legal: [ [ "Privacy Policy", "/privacy" ], [ "Terms of Service", "/terms" ] ] }
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# Rows may be written as tuples (above) or as hashes ({ label:, href: }); both
|
|
27
|
+
# normalize to the same symbol-keyed shape, which is what the partials read.
|
|
28
|
+
module Studio
|
|
29
|
+
module SiteFooter
|
|
30
|
+
DEFAULT_BOOKING_LABEL = "Schedule a call"
|
|
31
|
+
DEFAULT_ZOOM = 15
|
|
32
|
+
|
|
33
|
+
# The engine's own booking page keeps the footer for a signed-in viewer.
|
|
34
|
+
OWN_CONTROLLERS = %w[studio/bookings].freeze
|
|
35
|
+
|
|
36
|
+
# The only schemes a footer href may carry. Anything else, notably
|
|
37
|
+
# `javascript:` and `data:`, is never written into an href: the label is
|
|
38
|
+
# printed unlinked and the rejection is reported once.
|
|
39
|
+
SAFE_SCHEMES = %w[http https mailto tel].freeze
|
|
40
|
+
REPORTED_LIMIT = 100
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Where a rejected href is reported: a callable taking the message. The
|
|
44
|
+
# default writes one warning to the Rails log (or to stderr without Rails).
|
|
45
|
+
attr_writer :reporter
|
|
46
|
+
|
|
47
|
+
def reporter
|
|
48
|
+
@reporter ||= lambda do |message|
|
|
49
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
50
|
+
::Rails.logger.warn(message)
|
|
51
|
+
else
|
|
52
|
+
warn(message)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reported
|
|
58
|
+
@reported ||= {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reset_reported!
|
|
62
|
+
@reported = {}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# A relative path (no scheme), or one of SAFE_SCHEMES. A control character
|
|
69
|
+
# anywhere fails it: browsers drop tabs and newlines inside a scheme, so
|
|
70
|
+
# "java\tscript:" would otherwise read as a relative path here and as
|
|
71
|
+
# script there.
|
|
72
|
+
def safe_href?(href)
|
|
73
|
+
string = href.to_s
|
|
74
|
+
return false if string.match?(/[\x00-\x1f\x7f]/)
|
|
75
|
+
|
|
76
|
+
scheme = string[/\A\s*([a-z][a-z0-9+.\-]*):/i, 1]
|
|
77
|
+
scheme.nil? || SAFE_SCHEMES.include?(scheme.downcase)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The href when it may be linked, else nil, reported once per value.
|
|
81
|
+
def safe_href(value, where)
|
|
82
|
+
raw = value.nil? || value == false ? nil : value.to_s
|
|
83
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
84
|
+
return raw.strip if safe_href?(raw)
|
|
85
|
+
|
|
86
|
+
key = "#{where}:#{raw}"
|
|
87
|
+
unless SiteFooter.reported.key?(key) || SiteFooter.reported.size >= REPORTED_LIMIT
|
|
88
|
+
SiteFooter.reported[key] = true
|
|
89
|
+
SiteFooter.reporter.call(
|
|
90
|
+
"[studio.site_footer] #{where} #{raw[0, 80].inspect} is not linked: only relative paths, " \
|
|
91
|
+
"http:, https:, mailto: and tel: are allowed"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate!(declared)
|
|
98
|
+
return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
|
|
99
|
+
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"Studio.site_footer must be nil, a Hash, or a callable receiving the view " \
|
|
102
|
+
"(got #{declared.class}). See docs/SITE_FOOTER.md."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The normalized facts, or nil when the app declared no footer.
|
|
106
|
+
#
|
|
107
|
+
# name: the site name the engine resolved (the site identity's title)
|
|
108
|
+
# logo: the logo the engine resolved (the navbar logo), or nil
|
|
109
|
+
# booking_path: the booking page's path when the engine draws it, or nil
|
|
110
|
+
def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
|
|
111
|
+
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
112
|
+
return nil if raw.nil?
|
|
113
|
+
|
|
114
|
+
facts = symbolize(raw)
|
|
115
|
+
site_name = text(facts[:name]) || text(name)
|
|
116
|
+
booking = symbolize(facts[:booking] || {})
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
name: site_name,
|
|
120
|
+
wordmark: wordmark(facts[:wordmark], site_name),
|
|
121
|
+
logo: facts.key?(:logo) ? text(facts[:logo]) : text(logo),
|
|
122
|
+
logo_invert: facts[:logo_invert] ? true : false,
|
|
123
|
+
home_path: safe_href(facts[:home_path], "home_path") || "/",
|
|
124
|
+
tagline: text(facts[:tagline]),
|
|
125
|
+
email: text(facts[:email]),
|
|
126
|
+
address: address(facts),
|
|
127
|
+
social: Array(facts[:social]).filter_map { |row| social(row) },
|
|
128
|
+
columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
|
|
129
|
+
legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
|
|
130
|
+
booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
|
|
131
|
+
booking_title: text(booking[:title])
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Where the footer shows. A visitor always gets it; a signed-in viewer gets
|
|
136
|
+
# it only on the controllers the app lists, because every other signed-in
|
|
137
|
+
# page is a working surface. `controllers` entries match the controller's
|
|
138
|
+
# name ("landing") or its path ("studio/bookings").
|
|
139
|
+
def visible?(logged_in:, controller_name:, controller_path: nil, controllers: [])
|
|
140
|
+
return true unless logged_in
|
|
141
|
+
|
|
142
|
+
listed = Array(controllers).map(&:to_s)
|
|
143
|
+
listed.include?(controller_name.to_s) || (!controller_path.nil? && listed.include?(controller_path.to_s))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The default rule, asked of a view: the answer Studio.site_footer_visible
|
|
147
|
+
# gives until an app replaces it. An app narrowing the rule composes with it:
|
|
148
|
+
#
|
|
149
|
+
# config.site_footer_visible = ->(view) {
|
|
150
|
+
# Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("app/")
|
|
151
|
+
# }
|
|
152
|
+
def default_visible?(view, controllers: nil)
|
|
153
|
+
controllers = Studio.site_footer_controllers if controllers.nil? && defined?(Studio.site_footer_controllers)
|
|
154
|
+
visible?(
|
|
155
|
+
logged_in: view.respond_to?(:logged_in?) && view.logged_in? ? true : false,
|
|
156
|
+
controller_name: view.respond_to?(:controller_name) ? view.controller_name : nil,
|
|
157
|
+
controller_path: view.respond_to?(:controller_path) ? view.controller_path : nil,
|
|
158
|
+
controllers: Array(controllers) + OWN_CONTROLLERS
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_visible!(rule)
|
|
163
|
+
return if rule.respond_to?(:call)
|
|
164
|
+
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"Studio.site_footer_visible must be a callable receiving the view (got #{rule.class}). " \
|
|
167
|
+
"See docs/SITE_FOOTER.md."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ---- normalization ------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
# Two parts: everything but the last word, then the last word, which the
|
|
173
|
+
# footer prints in the primary colour (the navbar splits app_name the same
|
|
174
|
+
# way). A one-word name has an empty first part.
|
|
175
|
+
def wordmark(declared, name)
|
|
176
|
+
parts = Array(declared).map { |part| text(part) }.compact
|
|
177
|
+
return [parts[0..-2].join(" "), parts.last] if parts.length >= 2
|
|
178
|
+
|
|
179
|
+
words = (parts.first || name).to_s.split
|
|
180
|
+
return nil if words.empty?
|
|
181
|
+
|
|
182
|
+
[words[0..-2].join(" "), words.last]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The address may be nested under :address or written flat (street:,
|
|
186
|
+
# city_line:, lat:, lng:, directions_url:). No street and no city line means
|
|
187
|
+
# no address at all: no Location band and no map.
|
|
188
|
+
def address(facts)
|
|
189
|
+
declared = facts[:address]
|
|
190
|
+
source = declared.nil? ? facts : symbolize(declared)
|
|
191
|
+
street = text(source[:street])
|
|
192
|
+
city_line = text(source[:city_line])
|
|
193
|
+
return nil if street.nil? && city_line.nil?
|
|
194
|
+
|
|
195
|
+
full = [street, city_line].compact.join(", ")
|
|
196
|
+
lat = coordinate(source[:lat])
|
|
197
|
+
lng = coordinate(source[:lng])
|
|
198
|
+
{
|
|
199
|
+
street: street,
|
|
200
|
+
city_line: city_line,
|
|
201
|
+
full: full,
|
|
202
|
+
lat: lat,
|
|
203
|
+
lng: lng,
|
|
204
|
+
map: !lat.nil? && !lng.nil?,
|
|
205
|
+
zoom: (source[:zoom] || DEFAULT_ZOOM).to_i,
|
|
206
|
+
# A directions_url that may not be linked falls back to the default, so
|
|
207
|
+
# the address and the map's no-script link still lead somewhere.
|
|
208
|
+
directions_url: safe_href(source[:directions_url], "directions_url") ||
|
|
209
|
+
"https://www.google.com/maps/dir/?api=1&destination=#{CGI.escape(full)}"
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def coordinate(value)
|
|
214
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
215
|
+
|
|
216
|
+
Float(value)
|
|
217
|
+
rescue ArgumentError, TypeError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# [ label, icon, url ] or { label:, icon:, url: }. A nil url renders the
|
|
222
|
+
# icon unlinked.
|
|
223
|
+
def social(row)
|
|
224
|
+
label, icon, url =
|
|
225
|
+
if row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
226
|
+
hash = symbolize(row)
|
|
227
|
+
[hash[:label], hash[:icon], hash[:url] || hash[:href]]
|
|
228
|
+
else
|
|
229
|
+
Array(row)
|
|
230
|
+
end
|
|
231
|
+
label = text(label)
|
|
232
|
+
return nil if label.nil?
|
|
233
|
+
|
|
234
|
+
{ label: label, icon: (icon || label).to_s.downcase.to_sym, url: safe_href(url, "social url"),
|
|
235
|
+
unlinked: !text(url).nil? && safe_href(url, "social url").nil? }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# [ heading, links ] or { heading:, links: }.
|
|
239
|
+
def column(row, booking_path)
|
|
240
|
+
heading, links =
|
|
241
|
+
if row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
242
|
+
hash = symbolize(row)
|
|
243
|
+
[hash[:heading] || hash[:title], hash[:links]]
|
|
244
|
+
else
|
|
245
|
+
Array(row)
|
|
246
|
+
end
|
|
247
|
+
heading = text(heading)
|
|
248
|
+
links = Array(links).filter_map { |link_row| link(link_row, booking_path) }
|
|
249
|
+
return nil if heading.nil? && links.empty?
|
|
250
|
+
|
|
251
|
+
{ heading: heading, links: links }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# [ label, href ], [ label, href, { booking: true } ] or { label:, href:,
|
|
255
|
+
# 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 engine's booking page, opens
|
|
257
|
+
# the booking popup (the href stays as the fallback).
|
|
258
|
+
def link(row, booking_path = nil)
|
|
259
|
+
label, href, options =
|
|
260
|
+
if row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
261
|
+
hash = symbolize(row)
|
|
262
|
+
[hash[:label], hash[:href] || hash[:path] || hash[:url], hash]
|
|
263
|
+
else
|
|
264
|
+
Array(row)
|
|
265
|
+
end
|
|
266
|
+
label = text(label)
|
|
267
|
+
return nil if label.nil?
|
|
268
|
+
|
|
269
|
+
declared = text(href)
|
|
270
|
+
href = safe_href(href, "link href")
|
|
271
|
+
options = options.respond_to?(:to_h) ? symbolize(options) : {}
|
|
272
|
+
booking = !href.nil? && (options[:booking] ? true : (!booking_path.nil? && href == booking_path))
|
|
273
|
+
{
|
|
274
|
+
label: label,
|
|
275
|
+
href: href,
|
|
276
|
+
# A nil href is a page that does not exist yet. A REFUSED href is not
|
|
277
|
+
# that: the label prints plain, with no "coming soon".
|
|
278
|
+
disabled: declared.nil?,
|
|
279
|
+
unlinked: !declared.nil? && href.nil?,
|
|
280
|
+
external: !href.nil? && href.match?(%r{\Ahttps?://}i),
|
|
281
|
+
booking: booking
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def text(value)
|
|
286
|
+
string = value.to_s.strip
|
|
287
|
+
value.nil? || value == false || string.empty? ? nil : string
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def symbolize(hash)
|
|
291
|
+
hash.to_h.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
data/lib/studio/version.rb
CHANGED