studio-engine 0.82.0 → 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 +92 -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/concerns/studio/link_preview_bots.rb +45 -2
- data/app/controllers/studio/bookings_controller.rb +22 -0
- data/app/controllers/studio/site_identities_controller.rb +15 -3
- data/app/helpers/studio/link_preview_helper.rb +54 -6
- data/app/helpers/studio/site_footer_helper.rb +180 -0
- data/app/models/studio/site_identity.rb +39 -3
- data/app/views/layouts/studio/_link_preview_tags.html.erb +12 -1
- 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/generators/studio/site_identity/site_identity_generator.rb +6 -2
- data/lib/studio/booking.rb +47 -0
- data/lib/studio/engine.rb +2 -0
- data/lib/studio/link_preview.rb +91 -0
- data/lib/studio/site_footer.rb +294 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +105 -0
- metadata +18 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<%# Styles and scripts for the booking frame and the booking popup. Rendered
|
|
2
|
+
once per page, by `studio_booking_assets` (the frame and the popup both ask).
|
|
3
|
+
Scoped CSS for the reason studio/site_footer/_assets gives. %>
|
|
4
|
+
<style>
|
|
5
|
+
/* The inline frame. Google's page cannot be themed, so the panel is white. */
|
|
6
|
+
/* content-box, so the heights below are the WINDOW onto the frame whatever
|
|
7
|
+
reset the host runs: under border-box the border would eat 2px of the crop. */
|
|
8
|
+
.booking-frame { box-sizing: content-box; margin-inline: auto; overflow: hidden; background: #fff; border-radius: .75rem;
|
|
9
|
+
border: 1px solid var(--color-border, rgb(127 127 127 / .25));
|
|
10
|
+
/* A booking link on this page scrolls here; land below the pinned navbar. */
|
|
11
|
+
scroll-margin-top: calc(var(--nav-bottom, 4rem) + 1rem); }
|
|
12
|
+
.booking-frame iframe { display: block; width: 100%; border: 0; height: 1200px; }
|
|
13
|
+
@media (min-width: 640px) {
|
|
14
|
+
.booking-frame iframe { height: 732px; }
|
|
15
|
+
.booking-frame-cropped { height: 414px; transition: height .25s ease; }
|
|
16
|
+
.booking-frame-cropped iframe { margin-top: -205px; transition: margin-top .25s ease; }
|
|
17
|
+
.booking-frame-cropped.is-open { height: 732px; }
|
|
18
|
+
.booking-frame-cropped.is-open iframe { margin-top: 0; }
|
|
19
|
+
}
|
|
20
|
+
/* Scripts off: the frame never gets its src, so it is hidden and a plain link
|
|
21
|
+
stands in its place (the <noscript> in studio/booking/_frame). */
|
|
22
|
+
.booking-frame-noscript { margin: 0; padding: 3rem 1.5rem; text-align: center; color: #111; }
|
|
23
|
+
.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); }
|
|
25
|
+
.booking-frame-note a { color: var(--color-primary, #8b5cf6); }
|
|
26
|
+
.booking-frame-note a:hover { text-decoration: underline; }
|
|
27
|
+
|
|
28
|
+
/* The popup. */
|
|
29
|
+
.booking-popup { width: min(960px, calc(100vw - 2rem)); height: min(800px, calc(100vh - 3rem)); height: min(800px, calc(100dvh - 3rem)); max-width: none; max-height: none;
|
|
30
|
+
/* Tailwind's reset zeroes a dialog's margin, which is what centres it. */
|
|
31
|
+
margin: auto; inset: 0;
|
|
32
|
+
padding: 0; border: 1px solid rgb(127 127 127 / .3); border-radius: .75rem; overflow: hidden;
|
|
33
|
+
background: var(--color-surface, #fff); color: var(--color-text-body, inherit); }
|
|
34
|
+
.booking-popup[open] { display: flex; flex-direction: column; }
|
|
35
|
+
.booking-popup::backdrop { background: rgb(15 12 31 / .72); backdrop-filter: blur(3px); }
|
|
36
|
+
.booking-popup-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0;
|
|
37
|
+
padding: .6rem .75rem .6rem 1.1rem; }
|
|
38
|
+
.booking-popup-title { font-weight: 700; color: var(--color-text, inherit); }
|
|
39
|
+
/* THE WAY OUT HAS TO BE UNMISSABLE. Once focus is inside Google's frame the
|
|
40
|
+
Escape key goes to Google, not to this dialog, and nothing here can change
|
|
41
|
+
that. So Close is a solid, full-size button, not a quiet glyph, and a click
|
|
42
|
+
anywhere on the backdrop closes too. */
|
|
43
|
+
/* Ink on surface, inverted: the theme's text colour as the fill and its
|
|
44
|
+
surface as the label. That pair is the theme's own highest contrast in both
|
|
45
|
+
modes (white on the primary measured 3.10:1, under the 4.5:1 a label needs). */
|
|
46
|
+
.booking-popup-close { flex: none; min-height: 2.75rem; padding: .5rem 1.1rem; border-radius: .5rem; border: 0;
|
|
47
|
+
background: var(--color-text, #111827); color: var(--color-surface, #fff); font: inherit; font-size: .95rem; font-weight: 700; cursor: pointer; }
|
|
48
|
+
.booking-popup-close:hover { filter: brightness(1.12); }
|
|
49
|
+
.booking-popup-close:focus-visible { outline: 3px solid var(--color-primary, #8b5cf6); outline-offset: 2px; }
|
|
50
|
+
/* The page behind does not scroll while the dialog is open. Pure CSS, so every
|
|
51
|
+
way of closing it (the button, the backdrop, Escape) lifts the lock. On html
|
|
52
|
+
rather than body for the reason studio/modals/_host gives. */
|
|
53
|
+
html:has(dialog[data-booking-dialog][open]) { overflow: hidden; }
|
|
54
|
+
.booking-popup iframe { flex: 1; width: 100%; border: 0; background: #fff; }
|
|
55
|
+
@media (prefers-reduced-motion: reduce) { .booking-frame, .booking-frame iframe { transition: none; } }
|
|
56
|
+
</style>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
// The inline frame: assign src after `load`, once the frame is near the
|
|
60
|
+
// viewport, and open the crop when focus moves into the frame.
|
|
61
|
+
//
|
|
62
|
+
// THE GUARDS ARE THE ENGINE'S OWN NAMES, AND THE ELEMENTS ARE THE ENGINE'S OWN
|
|
63
|
+
// (data-studio-booking), for the reason studio/site_footer/_assets gives for
|
|
64
|
+
// the map: an app that still renders a local booking script guards it with
|
|
65
|
+
// the unprefixed names and marks its frame, dialog and links with the same
|
|
66
|
+
// data-booking-* attributes. Sharing either lets one script stand the other
|
|
67
|
+
// down across a Turbo visit, or act on elements that are not its own.
|
|
68
|
+
(function () {
|
|
69
|
+
if (window.__studioBookingFramesArmed) return;
|
|
70
|
+
window.__studioBookingFramesArmed = true;
|
|
71
|
+
|
|
72
|
+
function arm() {
|
|
73
|
+
document.querySelectorAll('iframe[data-booking-frame][data-studio-booking][data-src]').forEach(function (frame) {
|
|
74
|
+
if (frame.__bookingArmed) return;
|
|
75
|
+
frame.__bookingArmed = true;
|
|
76
|
+
if (typeof IntersectionObserver !== 'function') { frame.src = frame.dataset.src; return; }
|
|
77
|
+
var watcher = new IntersectionObserver(function (entries) {
|
|
78
|
+
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
79
|
+
watcher.disconnect();
|
|
80
|
+
frame.src = frame.dataset.src;
|
|
81
|
+
}, { rootMargin: '200px' });
|
|
82
|
+
watcher.observe(frame);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function armAfterLoad() {
|
|
87
|
+
if (document.readyState === 'complete') arm();
|
|
88
|
+
else window.addEventListener('load', arm, { once: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Focus moving into the frame is the one signal a cross-origin frame gives
|
|
92
|
+
// its parent. It means the visitor has started using the calendar.
|
|
93
|
+
window.addEventListener('blur', function () {
|
|
94
|
+
var active = document.activeElement;
|
|
95
|
+
if (!active || !active.matches || !active.matches('iframe[data-booking-frame][data-studio-booking]')) return;
|
|
96
|
+
var wrap = active.closest('[data-booking-wrap][data-studio-booking]');
|
|
97
|
+
if (wrap) wrap.classList.add('is-open');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
document.addEventListener('turbo:load', armAfterLoad);
|
|
101
|
+
armAfterLoad();
|
|
102
|
+
})();
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<script>
|
|
106
|
+
// Any a[data-booking-popup] link opens the booking dialog instead of leaving
|
|
107
|
+
// the page. Delegated from document, so it survives Turbo body swaps. A
|
|
108
|
+
// modified click (new tab, new window) is left alone, and so is everything
|
|
109
|
+
// when the dialog is missing or <dialog> is unsupported: the link's own href
|
|
110
|
+
// then takes the visitor to the booking page.
|
|
111
|
+
//
|
|
112
|
+
// ON A PAGE THAT ALREADY SHOWS THE INLINE FRAME the link goes to that frame
|
|
113
|
+
// instead: it scrolls there, opens the crop and moves focus in. A popup would
|
|
114
|
+
// be a second copy of the calendar on top of the first.
|
|
115
|
+
(function () {
|
|
116
|
+
if (window.__studioBookingPopupArmed) return;
|
|
117
|
+
window.__studioBookingPopupArmed = true;
|
|
118
|
+
|
|
119
|
+
document.addEventListener('click', function (event) {
|
|
120
|
+
var link = event.target.closest && event.target.closest('a[data-booking-popup][data-studio-booking]');
|
|
121
|
+
if (!link) return;
|
|
122
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
123
|
+
|
|
124
|
+
var inline = document.querySelector('iframe[data-booking-frame][data-studio-booking]');
|
|
125
|
+
if (inline) {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
var wrap = inline.closest('[data-booking-wrap][data-studio-booking]');
|
|
128
|
+
if (wrap) wrap.classList.add('is-open');
|
|
129
|
+
// Before `load` the frame keeps waiting: its own script assigns src then.
|
|
130
|
+
if (!inline.getAttribute('src') && document.readyState === 'complete') inline.src = inline.dataset.src;
|
|
131
|
+
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
132
|
+
(wrap || inline).scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' });
|
|
133
|
+
inline.focus({ preventScroll: true });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
var dialog = document.querySelector('dialog[data-booking-dialog][data-studio-booking]');
|
|
138
|
+
if (!dialog || typeof dialog.showModal !== 'function') return;
|
|
139
|
+
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
var frame = dialog.querySelector('iframe[data-booking-popup-frame]');
|
|
142
|
+
if (frame && !frame.getAttribute('src')) frame.src = frame.dataset.src;
|
|
143
|
+
if (!dialog.open) dialog.showModal();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// A click on the backdrop lands on the dialog element itself.
|
|
147
|
+
document.addEventListener('click', function (event) {
|
|
148
|
+
if (event.target.matches && event.target.matches('dialog[data-booking-dialog][data-studio-booking]')) event.target.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Never snapshot the dialog open: a restored page would show it stuck. And
|
|
152
|
+
// never snapshot its frame loaded: a restored page would ask Google for a
|
|
153
|
+
// popup nobody has opened. The src goes back to waiting in data-src.
|
|
154
|
+
document.addEventListener('turbo:before-cache', function () {
|
|
155
|
+
var dialog = document.querySelector('dialog[data-booking-dialog][data-studio-booking]');
|
|
156
|
+
if (!dialog) return;
|
|
157
|
+
if (dialog.open) dialog.close();
|
|
158
|
+
var frame = dialog.querySelector('iframe[data-booking-popup-frame]');
|
|
159
|
+
if (frame) frame.removeAttribute('src');
|
|
160
|
+
});
|
|
161
|
+
})();
|
|
162
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# Google's inline booking page for the app's appointment schedule
|
|
2
|
+
(Studio.booking_url). Rendered by `studio_booking_frame`. It fills its
|
|
3
|
+
container, so it lines up with the cards around it. Google lays it out by
|
|
4
|
+
width: month beside the slots from about 600px, stacked below that. It
|
|
5
|
+
cannot be themed, so it sits on a white panel in both themes.
|
|
6
|
+
|
|
7
|
+
CROPPED AT REST, WHOLE IN USE. From 640px up the wrapper shows only the
|
|
8
|
+
"Select an appointment time" box: Google's header above it and its credit
|
|
9
|
+
lines below are clipped (measured in the frame at 640-862px wide: the box
|
|
10
|
+
spans 211-613px of a 732px page). The crop cannot stay on, because the form
|
|
11
|
+
that opens when a slot is picked is a dialog centred in the frame's FULL
|
|
12
|
+
height (93-640px), so a cropped frame cuts off its title and its Book
|
|
13
|
+
button. A cross-origin frame reports no clicks, but the parent window does
|
|
14
|
+
lose focus to it; on that signal the wrapper opens to the full frame.
|
|
15
|
+
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
|
+
|
|
19
|
+
THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
|
|
20
|
+
party, and a frame that starts loading before the window's `load` event
|
|
21
|
+
holds that event open for as long as Google takes to answer. So the URL
|
|
22
|
+
waits in data-src and the script in studio/booking/_assets assigns it only
|
|
23
|
+
AFTER `load`, once the frame is near the viewport.
|
|
24
|
+
|
|
25
|
+
WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
|
|
26
|
+
empty frame and puts a plain link to Google's booking page in its place. %>
|
|
27
|
+
<%= studio_booking_assets %>
|
|
28
|
+
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking>
|
|
29
|
+
<iframe data-booking-frame data-studio-booking
|
|
30
|
+
data-src="<%= studio_booking_embed_url %>"
|
|
31
|
+
title="<%= title %>"
|
|
32
|
+
width="100%" frameborder="0"></iframe>
|
|
33
|
+
<noscript>
|
|
34
|
+
<style>.booking-frame { height: auto !important; } .booking-frame iframe { display: none !important; }</style>
|
|
35
|
+
<p class="booking-frame-noscript"><a href="<%= Studio.booking_url %>" target="_blank" rel="noopener">Open the booking page to pick a time</a></p>
|
|
36
|
+
</noscript>
|
|
37
|
+
</div>
|
|
38
|
+
<p class="booking-frame-note">
|
|
39
|
+
Scheduling by Google Calendar. Trouble seeing it?
|
|
40
|
+
<a href="<%= Studio.booking_url %>" target="_blank" rel="noopener">Open the booking page</a>.
|
|
41
|
+
</p>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# The booking popup: Google's appointment page in a dialog, opened by any
|
|
2
|
+
a[data-booking-popup] link on the page (studio/booking/_assets carries the
|
|
3
|
+
script). Rendered once per page by `studio_booking_popup`; the site footer
|
|
4
|
+
calls it.
|
|
5
|
+
|
|
6
|
+
It does what Google's own scheduling-button script does, without loading
|
|
7
|
+
that script, its stylesheet and its icon font on every public page: the
|
|
8
|
+
frame is requested only when the dialog first opens. The links keep their
|
|
9
|
+
own href, so with scripts off, or on a modified click, they are ordinary
|
|
10
|
+
links to the booking page.
|
|
11
|
+
|
|
12
|
+
A NATIVE <dialog>, NOT THE MODAL HOST (studio/modals/_host), on purpose. The
|
|
13
|
+
host needs Alpine, its store, and each modal registered inside the host's
|
|
14
|
+
block in the layout, so the footer would stop being one layout line and an
|
|
15
|
+
app with no host would have no popup. Its card is padded and scrolls, where
|
|
16
|
+
this body is a full-bleed frame. And it mounts content through
|
|
17
|
+
<template x-if>, which would build a new frame, and ask Google again, on
|
|
18
|
+
every open; this dialog keeps the loaded one. showModal() gives the top
|
|
19
|
+
layer, the inert page and the focus trap without any of that.
|
|
20
|
+
|
|
21
|
+
ESCAPE HAS A LIMIT NOTHING HERE CAN LIFT. Once focus is inside Google's
|
|
22
|
+
frame, key presses go to Google (it is another origin), so Escape no longer
|
|
23
|
+
reaches the dialog. The Close button and a click on the backdrop always
|
|
24
|
+
work, which is why the button is a solid one. %>
|
|
25
|
+
<%= studio_booking_assets %>
|
|
26
|
+
<dialog class="booking-popup" data-booking-dialog data-studio-booking aria-label="<%= label %>">
|
|
27
|
+
<form method="dialog" class="booking-popup-bar">
|
|
28
|
+
<span class="booking-popup-title"><%= label %></span>
|
|
29
|
+
<button type="submit" class="booking-popup-close" data-booking-close>Close ✕</button>
|
|
30
|
+
</form>
|
|
31
|
+
<iframe data-booking-popup-frame
|
|
32
|
+
data-src="<%= studio_booking_embed_url %>"
|
|
33
|
+
title="<%= title %>" frameborder="0"></iframe>
|
|
34
|
+
</dialog>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# /schedule — the booking page (Studio::BookingsController), drawn by
|
|
2
|
+
Studio.draw_booking_routes. It is where a booking link lands when the popup
|
|
3
|
+
cannot open. An app that wants different words around the frame renders
|
|
4
|
+
`studio_booking_frame` in a view of its own instead. %>
|
|
5
|
+
<% label = studio_booking_label %>
|
|
6
|
+
<% content_for(:title, "#{label} · #{studio_site_footer_name}") %>
|
|
7
|
+
<% content_for(:full_width, true) %>
|
|
8
|
+
|
|
9
|
+
<section class="booking-page" data-booking-page>
|
|
10
|
+
<h1 class="booking-page-title"><%= label %></h1>
|
|
11
|
+
<p class="booking-page-lede">Pick a time that works and it goes straight on the calendar.</p>
|
|
12
|
+
<%= studio_booking_frame %>
|
|
13
|
+
</section>
|
|
14
|
+
|
|
15
|
+
<style>
|
|
16
|
+
.booking-page { max-width: 56rem; margin-inline: auto; padding: 4rem 1rem; }
|
|
17
|
+
.booking-page-title { margin: 0; text-align: center; font-size: 2.25rem; font-weight: 800; letter-spacing: -.025em;
|
|
18
|
+
color: var(--color-text, inherit); }
|
|
19
|
+
.booking-page-lede { margin: .75rem 0 2rem; text-align: center; font-size: 1.125rem; color: var(--color-text-secondary, inherit); }
|
|
20
|
+
</style>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<%# The site footer's styles and its map mount. Rendered once per page, by
|
|
2
|
+
`studio_site_footer_assets` (the footer and the map partial both ask).
|
|
3
|
+
|
|
4
|
+
SCOPED CSS, NOT UTILITIES. Everything the footer needs to lay out is here,
|
|
5
|
+
reading the theme's custom properties with a fallback beside each, so it
|
|
6
|
+
renders the same in an app whose Tailwind build has never seen these views. %>
|
|
7
|
+
<style>
|
|
8
|
+
.ftr { --ftr-primary: var(--color-primary, #8b5cf6); --ftr-primary-rgb: var(--color-primary-500-rgb, 139 92 246);
|
|
9
|
+
--ftr-line: var(--color-border, rgb(127 127 127 / .25)); --ftr-ink: var(--color-text, inherit);
|
|
10
|
+
background: var(--color-surface-alt, transparent); color: var(--color-text-body, inherit);
|
|
11
|
+
border-top: 1px solid var(--ftr-line); }
|
|
12
|
+
.ftr a { transition: color .15s ease, opacity .15s ease; }
|
|
13
|
+
.ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
|
|
14
|
+
.ftr-link { color: inherit; opacity: .78; text-decoration: none; overflow-wrap: anywhere; }
|
|
15
|
+
.ftr-link-disabled { opacity: .38; cursor: not-allowed; }
|
|
16
|
+
.ftr-link-plain { opacity: .78; }
|
|
17
|
+
.ftr-link:hover, .ftr-link:focus-visible { opacity: 1; color: var(--ftr-primary); }
|
|
18
|
+
|
|
19
|
+
/* Brand, then the link columns. Two tracks on a phone with the brand across
|
|
20
|
+
both; from 768px the links sit in one row (at most four across); from
|
|
21
|
+
1024px the brand joins that row. --ftr-n is the number of link columns. */
|
|
22
|
+
.ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
23
|
+
.ftr-brand { grid-column: 1 / -1; }
|
|
24
|
+
@media (min-width: 768px) { .ftr-cols { grid-template-columns: repeat(var(--ftr-n-md, 4), minmax(0, 1fr)); } }
|
|
25
|
+
@media (min-width: 1024px) {
|
|
26
|
+
.ftr-cols { grid-template-columns: minmax(0, 1.7fr) repeat(var(--ftr-n, 4), minmax(0, 1fr)); }
|
|
27
|
+
.ftr-brand { grid-column: auto; }
|
|
28
|
+
}
|
|
29
|
+
.ftr-home { display: inline-flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
|
|
30
|
+
.ftr-logo { width: 3rem; height: 3rem; object-fit: contain; }
|
|
31
|
+
/* A dark mark on a light page: inverted in light mode, as drawn in dark. */
|
|
32
|
+
.ftr-logo-invert { filter: invert(1); }
|
|
33
|
+
.dark .ftr-logo-invert { filter: none; }
|
|
34
|
+
.ftr-wordmark { font-size: 1.5rem; font-weight: 800; line-height: 1.25; letter-spacing: .14em;
|
|
35
|
+
text-transform: uppercase; color: var(--ftr-ink); }
|
|
36
|
+
.ftr-wordmark span { display: block; }
|
|
37
|
+
.ftr-wordmark-accent { color: var(--ftr-primary); }
|
|
38
|
+
.ftr-tagline { margin: 1.5rem 0 0; line-height: 1.625; color: var(--color-text-secondary, inherit); }
|
|
39
|
+
.ftr-email { margin: .75rem 0 0; }
|
|
40
|
+
.ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
|
|
41
|
+
.ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
|
|
42
|
+
border-radius: 9999px; background: var(--ftr-primary); color: #fff; transition: transform .15s ease, filter .15s ease; }
|
|
43
|
+
a.ftr-social:hover { transform: translateY(-2px); filter: brightness(1.12); }
|
|
44
|
+
.ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; }
|
|
45
|
+
.ftr-social-letter { font-weight: 700; font-size: .95rem; line-height: 1; }
|
|
46
|
+
.ftr-heading { margin: 0 0 1rem; font-size: 1rem; font-weight: 700; color: var(--ftr-ink); }
|
|
47
|
+
.ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
|
48
|
+
|
|
49
|
+
.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); }
|
|
51
|
+
.ftr-address { margin-top: 1rem; font-style: normal; line-height: 1.625; }
|
|
52
|
+
.ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; }
|
|
53
|
+
.ftr-legal p { margin: 0; }
|
|
54
|
+
.ftr-legal .ftr-copyright { margin-top: .5rem; color: var(--color-text-muted, inherit); }
|
|
55
|
+
/* With no map between them, a rule separates the legal line from what is above. */
|
|
56
|
+
.ftr-legal-ruled { border-top: 1px solid var(--ftr-line); }
|
|
57
|
+
|
|
58
|
+
/* The map. isolation keeps Leaflet's internal z-indexes (up to 1000) under
|
|
59
|
+
the pinned navbar and every modal. It reads the same properties outside
|
|
60
|
+
the footer, where .ftr has not set them. */
|
|
61
|
+
.ftr-map { position: relative; isolation: isolate; overflow: hidden;
|
|
62
|
+
background: rgb(var(--ftr-primary-rgb, var(--color-primary-500-rgb, 139 92 246)) / .08); }
|
|
63
|
+
/* One keyless basemap for both themes: in dark mode the light tiles are
|
|
64
|
+
inverted and pulled toward the site's violet rather than swapped. */
|
|
65
|
+
.dark .ftr-map .leaflet-tile-pane { filter: invert(1) hue-rotate(205deg) saturate(.55) brightness(.9) contrast(.92); }
|
|
66
|
+
.ftr-map-wide { width: 100%; height: clamp(16rem, 26vw, 26rem);
|
|
67
|
+
border-block: 1px solid var(--ftr-line, var(--color-border, rgb(127 127 127 / .25))); }
|
|
68
|
+
.ftr-map-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
69
|
+
padding: 1rem; text-align: center; font-size: .85rem; color: inherit; }
|
|
70
|
+
/* Leaflet puts .leaflet-container on the [data-footer-map] element ITSELF, so
|
|
71
|
+
this is a compound selector, not a descendant one. It keeps the tint behind
|
|
72
|
+
the tiles (Leaflet's own is #ddd) and the page's font in the controls. */
|
|
73
|
+
.ftr-map.leaflet-container { background: rgb(var(--ftr-primary-rgb, var(--color-primary-500-rgb, 139 92 246)) / .08); font: inherit; }
|
|
74
|
+
.ftr-map .leaflet-control-attribution { font-size: 9px; background: rgb(0 0 0 / .45); color: #ddd; }
|
|
75
|
+
.ftr-map .leaflet-control-attribution a { color: #fff; }
|
|
76
|
+
.ftr-map .leaflet-bar a { background: var(--color-surface, #fff); color: var(--color-text, #111); border-color: rgb(127 127 127 / .3); }
|
|
77
|
+
.ftr-pin { position: relative; width: 18px; height: 18px; }
|
|
78
|
+
.ftr-pin::before, .ftr-pin::after { content: ""; position: absolute; inset: 0; border-radius: 9999px;
|
|
79
|
+
background: var(--ftr-primary, var(--color-primary, #8b5cf6)); }
|
|
80
|
+
.ftr-pin::before { animation: ftr-ping 2.2s cubic-bezier(0, 0, .2, 1) infinite; opacity: .6; }
|
|
81
|
+
.ftr-pin::after { border: 3px solid #fff; box-shadow: 0 2px 8px rgb(0 0 0 / .45); }
|
|
82
|
+
@keyframes ftr-ping { 75%, 100% { transform: scale(3.2); opacity: 0; } }
|
|
83
|
+
@media (prefers-reduced-motion: reduce) { .ftr-pin::before { animation: none; } .ftr a, .ftr-social { transition: none; } }
|
|
84
|
+
</style>
|
|
85
|
+
|
|
86
|
+
<script>
|
|
87
|
+
// Mounts a Leaflet map on every [data-footer-map]. Leaflet (script and
|
|
88
|
+
// stylesheet) is fetched once, on demand, from the URLs the element carries,
|
|
89
|
+
// so a page with no map pays nothing for it. The basemap follows the site
|
|
90
|
+
// theme in CSS (see .dark .leaflet-tile-pane above). Tiles are OpenStreetMap's
|
|
91
|
+
// keyless ones; a basemap that needs an API key does not belong in a gem.
|
|
92
|
+
//
|
|
93
|
+
// NOTHING STARTS BEFORE THE WINDOW'S `load`, AND NOTHING UNTIL THE MAP IS NEAR
|
|
94
|
+
// THE VIEWPORT. The tiles are a third party's, and a request that starts
|
|
95
|
+
// before `load` holds that event open for as long as the third party takes to
|
|
96
|
+
// answer. The map is also at the bottom of every public page, where most
|
|
97
|
+
// visits never reach: those pay for neither Leaflet nor a single tile.
|
|
98
|
+
//
|
|
99
|
+
// THE GUARD IS THE ENGINE'S OWN NAME, AND THE ELEMENTS ARE THE ENGINE'S OWN. An
|
|
100
|
+
// app that still renders a local footer script (mcritchie-studio, until it
|
|
101
|
+
// adopts this one) guards it with the unprefixed name and marks its map
|
|
102
|
+
// [data-footer-map] too. Sharing either would let whichever script ran first
|
|
103
|
+
// in a Turbo session stand the other down and then fail on its elements: that
|
|
104
|
+
// app's map carries no data-leaflet-js, so there is nothing here to fetch.
|
|
105
|
+
(function () {
|
|
106
|
+
if (window.__studioFooterMapsArmed) return;
|
|
107
|
+
window.__studioFooterMapsArmed = true;
|
|
108
|
+
|
|
109
|
+
var TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
110
|
+
var ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
|
111
|
+
var maps = [];
|
|
112
|
+
var loading = null;
|
|
113
|
+
|
|
114
|
+
// Checked on every mount, not once: a page visit that rebuilds the head
|
|
115
|
+
// must not leave a mounted map without its stylesheet.
|
|
116
|
+
function ensureStylesheet(href) {
|
|
117
|
+
if (!href || document.querySelector('link[data-studio-leaflet]')) return;
|
|
118
|
+
var link = document.createElement('link');
|
|
119
|
+
link.rel = 'stylesheet';
|
|
120
|
+
link.href = href;
|
|
121
|
+
link.setAttribute('data-studio-leaflet', '');
|
|
122
|
+
document.head.appendChild(link);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadLeaflet(src) {
|
|
126
|
+
if (window.L) return Promise.resolve();
|
|
127
|
+
if (loading) return loading;
|
|
128
|
+
loading = new Promise(function (resolve, reject) {
|
|
129
|
+
var s = document.createElement('script');
|
|
130
|
+
s.src = src;
|
|
131
|
+
s.onload = resolve;
|
|
132
|
+
// A failed fetch must not be remembered: the next visit tries again.
|
|
133
|
+
s.onerror = function () { loading = null; s.remove(); reject(new Error('leaflet failed to load')); };
|
|
134
|
+
document.head.appendChild(s);
|
|
135
|
+
});
|
|
136
|
+
return loading;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mount(el) {
|
|
140
|
+
if (el.__footerMap) return;
|
|
141
|
+
var at = [parseFloat(el.dataset.lat), parseFloat(el.dataset.lng)];
|
|
142
|
+
if (isNaN(at[0]) || isNaN(at[1])) return;
|
|
143
|
+
// NO ONE-FINGER DRAG ON A TOUCH DEVICE. The map runs edge to edge, so on a
|
|
144
|
+
// phone a swipe that starts on it would pan the map instead of scrolling
|
|
145
|
+
// the page, and there is no gutter to scroll by. With dragging off Leaflet
|
|
146
|
+
// leaves touch-action at pan-x pan-y: one finger scrolls the page, and a
|
|
147
|
+
// pinch still zooms the map (touchZoom stays on, and moves it under the
|
|
148
|
+
// fingers). The zoom buttons and the directions link do the rest.
|
|
149
|
+
var map = L.map(el, { center: at, zoom: parseInt(el.dataset.zoom, 10) || 15,
|
|
150
|
+
scrollWheelZoom: false, attributionControl: true,
|
|
151
|
+
dragging: !L.Browser.mobile, touchZoom: true,
|
|
152
|
+
zoomControl: el.dataset.controls !== 'false' });
|
|
153
|
+
map.attributionControl.setPrefix(false);
|
|
154
|
+
L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map);
|
|
155
|
+
L.marker(at, { icon: L.divIcon({ className: '', html: '<div class="ftr-pin"></div>', iconSize: [18, 18], iconAnchor: [9, 9] }),
|
|
156
|
+
keyboard: false }).addTo(map);
|
|
157
|
+
// Page scroll stays page scroll until the visitor commits to the map.
|
|
158
|
+
map.on('click', function () { map.scrollWheelZoom.enable(); });
|
|
159
|
+
map.on('mouseout', function () { map.scrollWheelZoom.disable(); });
|
|
160
|
+
var fallback = el.querySelector('.ftr-map-fallback');
|
|
161
|
+
if (fallback) { el.__footerFallback = fallback; fallback.remove(); }
|
|
162
|
+
el.__footerMap = map;
|
|
163
|
+
maps.push(el);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fetchAndMount(el) {
|
|
167
|
+
ensureStylesheet(el.dataset.leafletCss);
|
|
168
|
+
loadLeaflet(el.dataset.leafletJs)
|
|
169
|
+
.then(function () { if (el.isConnected) mount(el); })
|
|
170
|
+
.catch(function () { /* the fallback link stays */ });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One observer per map: it fires once, when the map comes within 400px of
|
|
174
|
+
// the viewport, and is then dropped.
|
|
175
|
+
function watch(el) {
|
|
176
|
+
if (el.__footerMap || el.__footerWatch) return;
|
|
177
|
+
if (typeof IntersectionObserver !== 'function') { fetchAndMount(el); return; }
|
|
178
|
+
var watcher = new IntersectionObserver(function (entries) {
|
|
179
|
+
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
180
|
+
watcher.disconnect();
|
|
181
|
+
el.__footerWatch = null;
|
|
182
|
+
fetchAndMount(el);
|
|
183
|
+
}, { rootMargin: '400px' });
|
|
184
|
+
el.__footerWatch = watcher;
|
|
185
|
+
watcher.observe(el);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function armAll() { document.querySelectorAll('[data-footer-map][data-leaflet-js]').forEach(watch); }
|
|
189
|
+
|
|
190
|
+
function armAfterLoad() {
|
|
191
|
+
if (document.readyState === 'complete') armAll();
|
|
192
|
+
else window.addEventListener('load', armAll, { once: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A cached Turbo snapshot would restore Leaflet's DOM without its state, so
|
|
196
|
+
// the map is taken down and its fallback link put back before the snapshot.
|
|
197
|
+
document.addEventListener('turbo:before-cache', function () {
|
|
198
|
+
maps.forEach(function (el) {
|
|
199
|
+
var fallback = el.__footerFallback;
|
|
200
|
+
el.__footerMap.remove();
|
|
201
|
+
el.__footerMap = null;
|
|
202
|
+
if (fallback && !el.querySelector('.ftr-map-fallback')) el.appendChild(fallback);
|
|
203
|
+
});
|
|
204
|
+
maps = [];
|
|
205
|
+
});
|
|
206
|
+
document.addEventListener('turbo:load', armAfterLoad);
|
|
207
|
+
armAfterLoad();
|
|
208
|
+
})();
|
|
209
|
+
</script>
|
|
@@ -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 %>
|