357 lines
18 KiB
Text
357 lines
18 KiB
Text
<%
|
|
/* ---- Next occurrence -------------------------------------------------
|
|
All arithmetic happens on a UTC Date that carries the *Eastern calendar
|
|
date*, never a wall-clock instant. That keeps day-of-week and day-adding
|
|
correct across DST without needing a timezone library. public/js/main.js
|
|
runs the same rule so the live countdown agrees with this render and can
|
|
roll itself over without a reload. */
|
|
const TZ = site.timezone || "America/New_York";
|
|
const now = new Date();
|
|
const V = site.venue;
|
|
|
|
const todayISO = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
|
|
}).format(now);
|
|
|
|
const hourET = Number(
|
|
new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "2-digit", hourCycle: "h23" }).format(now)
|
|
) % 24;
|
|
|
|
const toDate = (iso) => { const [y, m, d] = iso.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d)); };
|
|
const isoOf = (dt) => dt.toISOString().slice(0, 10);
|
|
|
|
let next = toDate(todayISO);
|
|
const first = toDate(site.firstEvent);
|
|
if (next < first) next = first;
|
|
|
|
while (next.getUTCDay() !== site.weekday) next.setUTCDate(next.getUTCDate() + 1);
|
|
if (isoOf(next) === todayISO && hourET >= (site.endHour24 || 21)) next.setUTCDate(next.getUTCDate() + 7);
|
|
|
|
// Keep the FIRST skipped date: with two cancellations in a row the notice
|
|
// should name the soonest one people were expecting, not the last one.
|
|
const cancelled = site.cancelled || [];
|
|
let skippedWeek = null;
|
|
while (cancelled.includes(isoOf(next))) {
|
|
if (!skippedWeek) skippedWeek = isoOf(next);
|
|
next.setUTCDate(next.getUTCDate() + 7);
|
|
}
|
|
|
|
const nextISO = isoOf(next);
|
|
const isTonight = nextISO === todayISO;
|
|
const fmtDate = (opts) => new Intl.DateTimeFormat("en-US", Object.assign({ timeZone: "UTC" }, opts)).format(next);
|
|
const nextLong = fmtDate({ weekday: "long", month: "long", day: "numeric" });
|
|
const nextShort = fmtDate({ weekday: "short", month: "short", day: "numeric" });
|
|
const weekdayName = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "long" }).format(next);
|
|
|
|
// Eastern UTC offset on the event date, so the countdown target is a real instant.
|
|
const offLabel = new Intl.DateTimeFormat("en-US", { timeZone: TZ, timeZoneName: "shortOffset" })
|
|
.format(new Date(`${nextISO}T12:00:00Z`));
|
|
const offHours = Number((offLabel.match(/GMT([+-]?\d{1,2})/) || [, "-5"])[1]);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
const offStr = `${offHours < 0 ? "-" : "+"}${pad(Math.abs(offHours))}:00`;
|
|
const startISO = `${nextISO}T${pad(site.startHour24)}:00:00${offStr}`;
|
|
const endISO = `${nextISO}T${pad(site.endHour24)}:00:00${offStr}`;
|
|
|
|
const heroLine = isTonight
|
|
? `Tonight at ${V.name}, ${site.startTime} to ${site.endTime}. Grab a cue and meet the people building things in ${V.city}.`
|
|
: `Every ${weekdayName} at ${V.name}, ${site.startTime} to ${site.endTime}. The next one is ${nextLong}.`;
|
|
|
|
const details = [
|
|
{ label: "When", value: `Every ${weekdayName}` },
|
|
{ label: "Time", value: `${site.startTime} to ${site.endTime}` },
|
|
{ label: "Where", value: `${V.name}, ${V.street}`, href: V.mapUrl },
|
|
{ label: "Entry", value: site.entry },
|
|
];
|
|
%><!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><%= site.name %> | <%= site.tagline %></title>
|
|
<meta name="description" content="<%= site.description %>">
|
|
<meta name="theme-color" content="#06060b">
|
|
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:title" content="<%= site.name %>">
|
|
<meta property="og:description" content="<%= site.description %>">
|
|
<meta property="og:image" content="<%= site.images.share %>">
|
|
<meta property="og:image:width" content="1200">
|
|
<meta property="og:image:height" content="630">
|
|
<meta property="og:url" content="<%= site.url %>">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
|
|
<link rel="icon" href="/images/favicon.svg" type="image/svg+xml">
|
|
<link rel="apple-touch-icon" href="/images/favicon.svg">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="/css/style.css">
|
|
<link rel="preload" as="image" href="<%= site.images.hero %>">
|
|
|
|
<!-- Gates the scroll-reveal styles on JS being alive, so a blocked or failed
|
|
main.js leaves the page fully readable instead of blank. -->
|
|
<script>document.documentElement.className += " js";</script>
|
|
|
|
<script type="application/ld+json">
|
|
<%- JSON.stringify({
|
|
"@context": "https://schema.org",
|
|
"@type": "Event",
|
|
name: site.name,
|
|
description: site.description,
|
|
startDate: startISO,
|
|
endDate: endISO,
|
|
eventSchedule: { "@type": "Schedule", repeatFrequency: "P1W", byDay: "https://schema.org/Thursday", startTime: "19:00", endTime: "21:00" },
|
|
eventStatus: "https://schema.org/EventScheduled",
|
|
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
|
location: { "@type": "Place", name: V.fullName, address: { "@type": "PostalAddress", streetAddress: V.street, addressLocality: V.city, addressRegion: V.state, postalCode: V.zip, addressCountry: "US" } },
|
|
organizer: { "@type": "Organization", name: site.host.name, url: site.host.url },
|
|
isAccessibleForFree: true,
|
|
image: [site.images.hero],
|
|
url: site.url,
|
|
}) %>
|
|
</script>
|
|
</head>
|
|
<body>
|
|
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
|
|
<!-- Decorative blueprint layer. Everything here is driven by CSS scroll-driven
|
|
animation, so it costs no JavaScript and no scroll listener; where the
|
|
browser has no support it simply sits still. Hidden from assistive tech and
|
|
frozen under prefers-reduced-motion. -->
|
|
<div class="bp" aria-hidden="true">
|
|
<svg class="bp-table" viewBox="0 0 1200 640" fill="none" preserveAspectRatio="xMidYMid meet">
|
|
<rect class="bp-draw" x="24" y="24" width="1152" height="592" rx="40" pathLength="1" stroke="currentColor" stroke-width="3"/>
|
|
<rect class="bp-draw bp-d2" x="86" y="86" width="1028" height="468" rx="14" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<circle class="bp-draw bp-d3" cx="86" cy="86" r="30" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<circle class="bp-draw bp-d3" cx="600" cy="70" r="26" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<circle class="bp-draw bp-d3" cx="1114" cy="86" r="30" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<circle class="bp-draw bp-d3" cx="86" cy="554" r="30" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<circle class="bp-draw bp-d3" cx="600" cy="570" r="26" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<circle class="bp-draw bp-d3" cx="1114" cy="554" r="30" pathLength="1" stroke="currentColor" stroke-width="2"/>
|
|
<line class="bp-draw bp-d4" x1="330" y1="86" x2="330" y2="554" pathLength="1" stroke="currentColor" stroke-width="1.5" stroke-dasharray="6 10"/>
|
|
<path class="bp-draw bp-d4" d="M330 320 L980 320" pathLength="1" stroke="currentColor" stroke-width="1.5" stroke-dasharray="6 10"/>
|
|
<g stroke="currentColor" stroke-width="2">
|
|
<circle class="bp-draw bp-d5" pathLength="1" cx="900" cy="320" r="18"/>
|
|
<circle class="bp-draw bp-d5" pathLength="1" cx="936" cy="299" r="18"/><circle class="bp-draw bp-d5" pathLength="1" cx="936" cy="341" r="18"/>
|
|
<circle class="bp-draw bp-d5" pathLength="1" cx="972" cy="278" r="18"/><circle class="bp-draw bp-d5" pathLength="1" cx="972" cy="320" r="18"/><circle class="bp-draw bp-d5" pathLength="1" cx="972" cy="362" r="18"/>
|
|
</g>
|
|
</svg>
|
|
|
|
<svg class="bp-ball" viewBox="0 0 64 64" fill="none">
|
|
<circle cx="32" cy="32" r="29" stroke="currentColor" stroke-width="3"/>
|
|
<ellipse cx="32" cy="32" rx="12" ry="29" stroke="currentColor" stroke-width="2"/>
|
|
<path d="M6 22 h52 M6 42 h52" stroke="currentColor" stroke-width="1.6"/>
|
|
</svg>
|
|
|
|
<svg class="bp-cue" viewBox="0 0 600 40" fill="none">
|
|
<path d="M4 20 L596 20" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
|
|
<path d="M560 14 L596 14 M560 26 L596 26" stroke="currentColor" stroke-width="2"/>
|
|
<circle cx="14" cy="20" r="7" stroke="currentColor" stroke-width="2"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<header class="nav" id="nav">
|
|
<a class="brand" href="/">
|
|
<img class="brand-mark" src="/images/onetouch-studio.svg" alt="" width="26" height="26">
|
|
<span class="brand-name"><%= site.name %></span>
|
|
</a>
|
|
<nav class="nav-right" aria-label="Primary">
|
|
<a class="nav-link" href="#details">Details</a>
|
|
<a class="nav-link" href="#expect">What to expect</a>
|
|
<a class="nav-link" href="#venue">The venue</a>
|
|
<a class="nav-cta" href="#rsvp" data-track="rsvp-nav">RSVP</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="main">
|
|
|
|
<!-- Hero: asymmetric split, copy left / photograph right -->
|
|
<section class="hero">
|
|
<div class="hero-copy">
|
|
<p class="eyebrow"><%= site.tagline %></p>
|
|
<h1><%= site.name %></h1>
|
|
<p class="hero-sub"><%= heroLine %></p>
|
|
<div class="hero-actions">
|
|
<a class="btn btn-primary" href="#rsvp" data-track="rsvp-hero">RSVP</a>
|
|
<a class="btn btn-ghost" href="#expect" data-track="hero-what-to-expect">What to expect</a>
|
|
</div>
|
|
</div>
|
|
<figure class="hero-figure">
|
|
<img src="<%= site.images.hero %>" width="1536" height="1024" fetchpriority="high"
|
|
alt="Four people talking beside a lit pool table in a dark bar.">
|
|
</figure>
|
|
</section>
|
|
|
|
<!-- Live countdown. Server-rendered values are the no-JS fallback; main.js
|
|
takes over, ticks, and recomputes the following Thursday on its own. -->
|
|
<section class="countdown-band">
|
|
<div class="countdown"
|
|
id="countdown"
|
|
data-start="<%= startISO %>"
|
|
data-end="<%= endISO %>"
|
|
data-weekday="<%= site.weekday %>"
|
|
data-start-hour="<%= site.startHour24 %>"
|
|
data-end-hour="<%= site.endHour24 %>"
|
|
data-first="<%= site.firstEvent %>"
|
|
data-cancelled="<%= (site.cancelled || []).join(',') %>">
|
|
<p class="countdown-label" id="countdown-label">
|
|
<%= isTonight ? "The Break is tonight" : "Until the next Break" %>
|
|
</p>
|
|
<div class="countdown-clock" id="countdown-clock" role="timer" aria-live="off">
|
|
<div class="unit"><span class="num" data-unit="days">-</span><span class="cap">days</span></div>
|
|
<div class="unit"><span class="num" data-unit="hours">-</span><span class="cap">hours</span></div>
|
|
<div class="unit"><span class="num" data-unit="minutes">-</span><span class="cap">min</span></div>
|
|
<div class="unit"><span class="num" data-unit="seconds">-</span><span class="cap">sec</span></div>
|
|
</div>
|
|
<p class="countdown-when" id="countdown-when"><%= nextLong %>, <%= site.startTime %> at <%= V.name %></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Details: display-value strip, vertical hairlines only -->
|
|
<section class="details" id="details">
|
|
<% if (skippedWeek) { %>
|
|
<p class="notice" role="status">No Break on <%= new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "long", day: "numeric" }).format(toDate(skippedWeek)) %>. Back on <%= nextLong %>.</p>
|
|
<% } %>
|
|
<dl class="detail-grid reveal">
|
|
<% details.forEach(function (d) { %>
|
|
<div class="detail">
|
|
<dt><%= d.label %></dt>
|
|
<dd><% if (d.href) { %><a href="<%= d.href %>" target="_blank" rel="noopener" data-track="details-map"><%= d.value %></a><% } else { %><%= d.value %><% } %></dd>
|
|
</div>
|
|
<% }) %>
|
|
</dl>
|
|
<p class="detail-foot">
|
|
<span<% if (isTonight) { %> class="is-live"<% } %>><%= isTonight ? "Happening tonight" : `Next: ${nextShort}` %></span>
|
|
<a class="ics" href="/assets/founders-break.ics" data-track="add-to-calendar">Add the whole series to your calendar</a>
|
|
</p>
|
|
</section>
|
|
|
|
<!-- Pitch: full-bleed editorial band over the break shot -->
|
|
<section class="pitch" style="--pitch-image: url('<%= site.images.breakShot %>')">
|
|
<div class="pitch-inner reveal">
|
|
<h2>We are building Gainesville's startup culture, one terrible pool shot at a time.</h2>
|
|
<p>Whether you are an engineer, a designer, a business strategist, or just an ambitious person driven to build things from the ground up, this is your crowd.</p>
|
|
<p class="pitch-kicker">Zero pool skills required. Seriously. We are here to build startups, not run the table.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- What to expect: bento, four cells, one photographic -->
|
|
<section class="expect" id="expect">
|
|
<h2 class="section-title reveal">What to expect</h2>
|
|
<div class="bento">
|
|
<figure class="cell cell-photo reveal">
|
|
<img src="<%= site.images.napkin %>" width="1024" height="1024" loading="lazy"
|
|
alt="Three students laughing over a napkin sketch at the rail of a pool table.">
|
|
</figure>
|
|
<% site.expect.forEach(function (item, i) { %>
|
|
<div class="cell cell-text<%= i === 2 ? " cell-wide" : "" %><%= i === 1 ? " cell-tint" : "" %> reveal">
|
|
<h3><%= item.title %></h3>
|
|
<p><%= item.body %></p>
|
|
</div>
|
|
<% }) %>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Venue -->
|
|
<section class="venue" id="venue">
|
|
<div class="venue-copy reveal">
|
|
<img class="venue-logo" src="<%= site.images.venueLogo %>" width="395" height="736"
|
|
alt="<%= V.fullName %>" loading="lazy">
|
|
<h2>We play at <%= V.name %>.</h2>
|
|
<p><%= V.note %></p>
|
|
<address>
|
|
<a href="<%= V.mapUrl %>" target="_blank" rel="noopener" data-track="venue-map"><%= V.street %><br><%= V.city %>, <%= V.state %> <%= V.zip %></a>
|
|
</address>
|
|
<p class="venue-meta">
|
|
<a href="<%= V.instagramUrl %>" target="_blank" rel="noopener" data-track="venue-instagram">@<%= V.instagram %></a>
|
|
<span>Pouring since <%= V.established %></span>
|
|
</p>
|
|
</div>
|
|
<figure class="venue-figure reveal">
|
|
<img src="<%= site.images.venue %>" width="1400" height="991" loading="lazy"
|
|
alt="The Silver Q storefront on West University Avenue at dusk.">
|
|
<figcaption>Look for the sign on West University Avenue.</figcaption>
|
|
</figure>
|
|
</section>
|
|
|
|
<!-- RSVP -->
|
|
<section class="rsvp" id="rsvp">
|
|
<div class="rsvp-panel reveal">
|
|
<!-- Everything the visitor interacts with lives in one wrapper so the
|
|
confirmation can replace the whole block. Hiding only the form left
|
|
the heading and the QR stranded above the confirmation. -->
|
|
<div id="rsvp-live">
|
|
<h2>RSVP for <%= isTonight ? "tonight" : nextLong %></h2>
|
|
<p class="rsvp-sub">Not required, but it helps us know how many tables to hold. We will email you a reminder the day before.</p>
|
|
|
|
<form id="rsvp-form" method="POST" action="/forms/rsvp" novalidate>
|
|
<input type="hidden" name="_redirect" value="/?rsvp=ok#rsvp">
|
|
<input type="hidden" name="event_date" value="<%= nextISO %>">
|
|
<p class="hp" aria-hidden="true"><label>Leave this empty<input type="text" name="company_website" tabindex="-1" autocomplete="off"></label></p>
|
|
|
|
<div class="field">
|
|
<label for="f-name">Name</label>
|
|
<input id="f-name" name="name" type="text" autocomplete="name" required>
|
|
<p class="field-error" id="e-name"></p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="f-email">Email</label>
|
|
<input id="f-email" name="email" type="email" autocomplete="email" inputmode="email" required>
|
|
<p class="field-help">For the weekly reminder. Nothing else.</p>
|
|
<p class="field-error" id="e-email"></p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="f-building">What are you building? <span class="opt">Optional</span></label>
|
|
<input id="f-building" name="building" type="text" autocomplete="off">
|
|
<p class="field-help">A few words is plenty. It helps us introduce you to the right people.</p>
|
|
</div>
|
|
|
|
<button class="btn btn-primary btn-block" type="submit" id="rsvp-submit" data-track="rsvp-submit">RSVP</button>
|
|
<p class="form-status" id="form-status" role="status" aria-live="polite"></p>
|
|
<p class="fine">One email a week at most. Unsubscribe any time.</p>
|
|
</form>
|
|
|
|
<!-- Deliberately dark-on-white: inverted QR codes are a coin flip on older
|
|
scanners, and this is going on flyers. -->
|
|
<div class="qr">
|
|
<a class="qr-card" href="#rsvp" aria-hidden="true" tabindex="-1">
|
|
<img src="<%= site.images.qr %>" width="1024" height="1024" loading="lazy"
|
|
alt="QR code linking to the RSVP form on this page.">
|
|
</a>
|
|
<div class="qr-copy">
|
|
<p class="qr-title">Scan to RSVP</p>
|
|
<p>Opens this page at the form. Screenshot it to share.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rsvp-done" id="rsvp-done" hidden>
|
|
<h3>You are on the list.</h3>
|
|
<p><%= isTonight ? "Tonight" : nextLong %> at <%= V.name %>, <%= V.street %>, <%= site.startTime %>. A confirmation is on its way to your inbox.</p>
|
|
<a class="ics" href="/assets/founders-break.ics" data-track="add-to-calendar">Add the whole series to your calendar</a>
|
|
<p class="done-extra">Bringing people? <a href="/flyer" data-track="flyer-after-rsvp">Grab the flyer and share it</a></p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<footer class="foot">
|
|
<p class="foot-line">Bring your ideas and a valid ID.</p>
|
|
<div class="foot-meta">
|
|
<a class="foot-host" href="<%= site.host.url %>" target="_blank" rel="noopener">
|
|
<img src="/images/onetouch-studio.svg" alt="" width="20" height="20">
|
|
<span>Hosted by <%= site.host.name %></span>
|
|
</a>
|
|
<a href="/flyer" data-track="flyer-footer">Printable flyer</a>
|
|
<a href="mailto:<%= site.host.email %>"><%= site.host.email %></a>
|
|
<a href="<%= V.mapUrl %>" target="_blank" rel="noopener" data-track="venue-map"><%= V.name %>, <%= V.street %></a>
|
|
</div>
|
|
</footer>
|
|
|
|
<script src="/js/main.js" data-site="<%= tenant.id %>" defer></script>
|
|
</body>
|
|
</html>
|