style(pulse): previous/upcoming gatherings strip below the hero card

Move the previous + upcoming dates out of the dark hero box into an editorial
strip beneath it (large serif day, terracotta eyebrow, "All gatherings" link
top-aligned on the right). Hero card keeps just the RSVP footer.
This commit is contained in:
Arlind 2026-06-18 16:05:09 +02:00
parent c0d4a6fdff
commit 436e19170e
2 changed files with 170 additions and 157 deletions

View file

@ -12,7 +12,9 @@ interface Props {
memberLabel?: string | null; // e.g. "MEMBER · 001"
}
const { event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null } = Astro.props;
const {
event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null,
} = Astro.props;
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
@ -49,51 +51,52 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
</div>
{event ? (
<>
<div class="hero-event">
<!-- Label sits above the date + title so it's clear they describe
the next event. -->
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<div class="hero-top">
<div class="hero-date">
<span class="hero-weekday">{weekday}</span>
<span class="hero-day">{dayPadded}</span>
<span class="hero-month-time">{monthShort} · {startTime}</span>
</div>
<div class="hero-event">
<!-- Label sits above the date + title so it's clear they describe
the next event. -->
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<div class="hero-top">
<div class="hero-date">
<span class="hero-weekday">{weekday}</span>
<span class="hero-day">{dayPadded}</span>
<span class="hero-month-time">{monthShort} · {startTime}</span>
</div>
<div class="hero-mid">
<h2 class="hero-title">{event.title}</h2>
<p class="hero-desc">{event.description}</p>
<p class="hero-location">{event.location}</p>
</div>
<div class="hero-mid">
<h2 class="hero-title">{event.title}</h2>
<p class="hero-desc">{event.description}</p>
<p class="hero-location">{event.location}</p>
</div>
</div>
<footer class="hero-foot">
<p class="hero-status">
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
</p>
<form method="POST" class="hero-actions">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={event.slug} />
{myRsvp === 'yes' ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
</>
) : (
<>
<button type="submit" name="status" value="no" class="hero-decline">Can't make it</button>
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
</>
)}
</form>
</footer>
</>
</div>
) : (
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
)}
<!-- RSVP row, pinned to the base of the card. -->
{event && (
<footer class="hero-foot">
<p class="hero-status">
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
</p>
<form method="POST" class="hero-actions">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={event.slug} />
{myRsvp === 'yes' ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
</>
) : (
<>
<button type="submit" name="status" value="no" class="hero-decline">Can't make it</button>
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
</>
)}
</form>
</footer>
)}
</article>
<style>
@ -262,10 +265,11 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
}
/* ── Bottom strip ────────────────────────────────────────────── */
/* RSVP row pinned to the base of the card. */
.hero-foot {
position: relative;
z-index: 1;
margin-top: auto; /* pin to the bottom of the taller card */
margin-top: auto;
border-top: 0.5px solid var(--ink-divider);
padding-top: 22px;
display: flex;

View file

@ -3,6 +3,7 @@ import AppLayout from '../layouts/AppLayout.astro';
import Avatar from '../components/Avatar.astro';
import EventHeroCard from '../components/EventHeroCard.astro';
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
import type { Event } from '../lib/db';
import {
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity,
@ -10,8 +11,7 @@ import {
getAllCabMembers, getPulseById, castOrChangeVote,
} from '../lib/db';
import {
timeOfDay, relativeTime,
eventKindLabel,
timeOfDay, relativeTime, eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format';
@ -75,13 +75,20 @@ function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
// Day / month·kind for the previous + upcoming strip below the hero card.
function fmtPart(opts: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
function alsoParts(ev: Event) {
return {
day: fmtPart({ day: 'numeric' }, ev.starts_at),
sub: `${fmtPart({ month: 'short' }, ev.starts_at).toUpperCase()} · ${eventKindLabel(ev.kind).toUpperCase()}`,
title: ev.title,
};
}
const prevAlso = previousEvent ? alsoParts(previousEvent) : null;
const upcAlso = upcomingAfterHero ? alsoParts(upcomingAfterHero) : null;
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length;
@ -121,36 +128,36 @@ const members = getAllCabMembers();
firstName={firstName}
memberLabel={memberNumberLabel}
/>
</section>
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
<section class="cascade also-coming-up" aria-label="Surrounding events">
<div class="also-list">
{previousEvent && (
<div class="also-item">
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
<div class="also-meta-col">
<span class="also-eyebrow">Previous</span>
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
<span class="also-title">{previousEvent.title}</span>
</div>
<!-- Previous + upcoming gatherings, sitting just below the box. -->
{(prevAlso || upcAlso) && (
<div class="also-strip">
<div class="also-list">
{prevAlso && (
<a href="/events" class="also-item">
<span class="also-day">{prevAlso.day}</span>
<span class="also-meta">
<span class="also-eyebrow">Previous</span>
<span class="also-sub">{prevAlso.sub}</span>
<span class="also-title">{prevAlso.title}</span>
</span>
</a>
)}
{prevAlso && upcAlso && <span class="also-divider" aria-hidden="true"></span>}
{upcAlso && (
<a href="/events" class="also-item">
<span class="also-day">{upcAlso.day}</span>
<span class="also-meta">
<span class="also-eyebrow">Upcoming</span>
<span class="also-sub">{upcAlso.sub}</span>
<span class="also-title">{upcAlso.title}</span>
</span>
</a>
)}
</div>
)}
{previousEvent && upcomingAfterHero && (
<span class="also-divider" aria-hidden="true"></span>
)}
{upcomingAfterHero && (
<div class="also-item">
<span class="also-day">{dayNum(upcomingAfterHero.starts_at)}</span>
<div class="also-meta-col">
<span class="also-eyebrow">Upcoming</span>
<span class="also-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span>
<span class="also-title">{upcomingAfterHero.title}</span>
</div>
</div>
)}
</div>
<a href="/events" class="also-link">All gatherings →</a>
<a href="/events" class="also-all">All gatherings →</a>
</div>
)}
</section>
<!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── -->
@ -282,11 +289,91 @@ const members = getAllCabMembers();
dispatch → 'Earlier' gap stays tight at the original 48px because
they're the same story. */
.hero-slot { margin-top: 24px; } /* first section, below nav */
.also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */
.editorial-row { margin-top: 96px; } /* also → editorial */
.editorial-row { margin-top: 96px; } /* hero → editorial */
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
.council-section{ margin-top: 96px; } /* roadmap → council */
/* ── Previous + upcoming strip (below the hero box) ───────────── */
.also-strip {
margin-top: 18px;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px 32px;
flex-wrap: wrap;
}
.also-list {
display: flex;
align-items: center;
gap: 28px;
min-width: 0;
flex-wrap: wrap;
}
.also-item {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.also-item:hover { opacity: 0.7; border-bottom: none; }
.also-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 32px;
line-height: 1;
color: var(--on-surface);
flex-shrink: 0;
}
.also-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.also-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
font-weight: 600;
}
.also-sub {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.also-title {
font-family: var(--font-serif);
font-size: 15px;
line-height: 1.25;
color: var(--on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 20rem;
}
.also-divider {
flex-shrink: 0;
width: 1px;
height: 34px;
background: rgba(0, 0, 0, 0.1);
}
.also-all {
flex-shrink: 0;
align-self: flex-start; /* sit at the top of the strip, not the baseline */
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.also-all:hover { opacity: 0.7; border-bottom: none; }
/* ── Cascade entry (first paint only) ─────────────────────────── */
.cascade {
opacity: 0;
@ -350,85 +437,8 @@ const members = getAllCabMembers();
.page { padding: 32px 20px 64px; }
.greeting { grid-template-columns: 1fr; align-items: start; }
.greeting-right { align-items: flex-start; }
/* "Also coming up" strip stacks and wraps instead of overflowing. */
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
.also-list { flex-wrap: wrap; gap: 12px 18px; }
.also-title { max-width: 70vw; }
}
/* ── 'Also coming up' strip (plain text on cream) ─────────────── */
.also-coming-up {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
gap: var(--space-5);
}
.also-list {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.also-divider {
display: inline-block;
width: 1px;
height: 28px;
background: rgba(0, 0, 0, 0.08);
transform: scaleX(1);
}
.also-item {
display: flex;
align-items: center;
gap: 12px;
}
.also-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 22px;
line-height: 1;
color: var(--on-surface);
}
.also-meta-col {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.also-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
font-weight: 600;
}
.also-month-kind {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.also-title {
font-size: 14px;
color: var(--on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
}
.also-link {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.also-link:hover { opacity: 0.8; border-bottom: none; }
/* ── Editorial row: dispatch (1.7fr) + pulse (1fr) ────────────── */
.editorial-row {
display: grid;
@ -750,6 +760,5 @@ const members = getAllCabMembers();
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.editorial-row { grid-template-columns: 1fr; gap: var(--space-8); }
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
}
</style>