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.
@@ -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> &middot;
36
+ booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> &middot;
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>
@@ -497,4 +497,7 @@
497
497
  <% end %>
498
498
  </div>
499
499
  </section>
500
+
501
+ <!-- Site footer and booking -->
502
+ <%= render "style/site_footer" %>
500
503
  </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
@@ -47,6 +47,8 @@ module Studio
47
47
  studio/canvas_confetti.js
48
48
  studio/studio_confetti.js
49
49
  studio/sortable.js
50
+ studio/leaflet.js
51
+ studio/leaflet.css
50
52
  studio/session.js
51
53
  studio/montserrat-latin.woff2
52
54
  studio/montserrat-latin-ext.woff2
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.82.1"
2
+ VERSION = "0.83.0"
3
3
  end