feat(component): EventHeroCard — the indigo card that carries /pulse

Self-contained component for the next gathering. Three-column grid:
  - Left (140px): 10px tracked weekday · 88px serif day numeral (zero-
    padded, line-height 0.85) · 11px tracked MONTH · HH:MM line. All on
    the bleached --on-ink cream.
  - Middle (1fr): 10px tracked NEXT GATHERING · KIND eyebrow · 26px serif
    title · 13px description (--on-ink-body @ 85%) · 12px location line
    (--on-ink-muted @ 65%).
  - Right (auto, min 140px): tracked duration label right-aligned, then
    up to 3 confirmed attendees as 'name + 18px avatar' rows, with a
    +N overflow chip beneath if more.
Bottom strip after a 0.5px --ink-divider top border, padding-top 22px:
  - Left: tracked '{capacity} SEATS · {n} CONFIRMED | CLOSES {DAY}' status
    line, with a low-opacity '|' divider character between the halves.
  - Right: form actions — 'Can't make it' link + cream pill 'Save your
    seat →'. If already confirmed, swaps to outlined 'You're confirmed
    ✓' + a small 'Change' link.
Empty state collapses the action strip and renders a serif italic
'Nothing scheduled yet — when we have something, you'll be the first to
know.' (the only italic on the card, mirroring the spec).

/pulse swap: the previous inline events-card markup (hero body + bundled
coming-up grid + see-all link) is replaced by a single <EventHeroCard>
slot. The 'Also coming up' strip lives outside the indigo card now and
will be built in step 6; for this commit a temporary comment marker
holds its slot. AvatarPile import is dropped from /pulse since
EventHeroCard renders the attendees list inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 10:50:43 +02:00
parent 096cdb00b6
commit 29fe1b7c92
2 changed files with 348 additions and 71 deletions

View file

@ -0,0 +1,337 @@
---
import Avatar from './Avatar.astro';
import type { Event, UserPublic } from '../lib/db';
import { eventKindLabel, redactName } from '../lib/format';
interface Props {
event: Event | null;
attendees: UserPublic[]; // confirmed (status='yes')
confirmedCount: number;
myRsvp: 'yes' | 'no' | 'interested' | null;
}
const { event, attendees, confirmedCount, myRsvp } = Astro.props;
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));
}
const dayPadded = event ? String(parseUtc(event.starts_at).getUTCDate()).padStart(2, '0') : '';
const weekday = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : '';
const monthShort = event ? fmt({ month: 'short' }, event.starts_at).toUpperCase() : '';
const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, event.starts_at) : '';
const closesDay = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : '';
const visibleAttendees = attendees.slice(0, 3);
const overflow = Math.max(0, attendees.length - visibleAttendees.length);
---
{event ? (
<article class="hero" aria-label={`Next gathering: ${event.title}`}>
<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">
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<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-meta">
{event.duration_label && (
<p class="hero-duration">{event.duration_label.toUpperCase()}</p>
)}
{visibleAttendees.length > 0 && (
<ul class="hero-attendees" aria-label="Confirmed attendees">
{visibleAttendees.map(u => (
<li class="hero-attendee">
<span class="hero-attendee-name">{redactName(u.name)}</span>
<Avatar id={u.id} name={u.name} size={18} />
</li>
))}
{overflow > 0 && (
<li class="hero-attendee">
<span class="hero-attendee-name">+{overflow} more</span>
<span class="hero-attendee-overflow" aria-hidden="true">+{overflow}</span>
</li>
)}
</ul>
)}
</div>
</div>
<footer class="hero-foot">
<p class="hero-status">
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
<span class="hero-status-divider" aria-hidden="true">|</span>
CLOSES {closesDay}
</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>
) : (
<article class="hero hero--empty">
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
</article>
)}
<style>
.hero {
background: var(--ink);
color: var(--on-ink);
border-radius: 14px;
padding: 32px 36px 28px;
display: flex;
flex-direction: column;
gap: 22px;
}
.hero-top {
display: grid;
grid-template-columns: 140px 1fr auto;
gap: 32px;
align-items: start;
}
/* ── Date column ─────────────────────────────────────────────── */
.hero-date {
display: flex;
flex-direction: column;
gap: 6px;
}
.hero-weekday {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
.hero-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 88px;
line-height: 0.85;
color: var(--on-ink);
}
.hero-month-time {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
/* ── Mid column ──────────────────────────────────────────────── */
.hero-mid {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
margin: 0;
}
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 26px;
line-height: 1.15;
color: var(--on-ink);
margin: 0;
}
.hero-desc {
font-size: 13px;
line-height: 1.55;
color: var(--on-ink-body);
margin: 0;
max-width: 380px;
}
.hero-location {
font-size: 12px;
color: var(--on-ink-muted);
margin: 0;
}
/* ── Right meta column ───────────────────────────────────────── */
.hero-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
min-width: 140px;
}
.hero-duration {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
text-align: right;
margin: 0;
}
.hero-attendees {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
}
.hero-attendee {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.hero-attendee-name {
font-family: var(--font-sans);
font-size: 11px;
color: var(--on-ink-muted);
}
.hero-attendee-overflow {
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 252, 247, 0.15);
color: var(--on-ink);
font-family: var(--font-sans);
font-size: 9px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ── Bottom strip ────────────────────────────────────────────── */
.hero-foot {
border-top: 0.5px solid var(--ink-divider);
padding-top: 22px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.hero-status {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
margin: 0;
}
.hero-status-divider {
color: var(--ink-divider);
margin: 0 10px;
}
.hero-actions {
display: flex;
align-items: center;
gap: 16px;
}
.hero-decline {
background: none;
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.hero-decline:hover { color: var(--on-ink); }
.hero-cta {
background: var(--on-ink);
color: var(--ink);
border: none;
padding: 9px 22px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.88; }
.hero-confirmed {
color: var(--on-ink);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 9px 22px;
border: 0.5px solid rgba(255, 252, 247, 0.3);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
}
.hero-change:hover { color: var(--on-ink); }
/* ── Empty state ─────────────────────────────────────────────── */
.hero--empty {
align-items: flex-start;
min-height: 200px;
justify-content: center;
}
.hero-empty {
font-family: var(--font-serif);
font-size: 20px;
color: var(--on-ink-body);
margin: 0;
max-width: 32rem;
}
.hero-empty em { font-style: italic; }
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.hero-top { grid-template-columns: 1fr; }
.hero-meta { align-items: flex-start; }
.hero-duration, .hero-attendee { justify-content: flex-start; }
}
</style>

View file

@ -1,7 +1,7 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import Avatar from '../components/Avatar.astro'; import Avatar from '../components/Avatar.astro';
import AvatarPile from '../components/AvatarPile.astro'; import EventHeroCard from '../components/EventHeroCard.astro';
import { import {
getUpcomingEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
@ -142,78 +142,18 @@ const members = getAllCabMembers();
)} )}
</section> </section>
<!-- ── Events (--ink card) ──────────────────────────────────── --> <!-- ── Hero event card (--ink) ──────────────────────────────── -->
{hero ? ( <section class="cascade hero-slot" aria-label="Next gathering">
<section class="cascade events-card" aria-label="Events"> <EventHeroCard
event={hero}
<!-- Hero — more air, vertically aligned columns --> attendees={heroAttendees}
<div class="hero-body"> confirmedCount={heroConfirmedCount}
<div class="hero-date"> myRsvp={heroMyRsvp}
<span class="hero-weekday">{weekday(hero.starts_at)}</span> />
<span class="hero-day">{dayNum(hero.starts_at)}</span>
<span class="hero-month">{monthShort(hero.starts_at)}</span>
</div>
<div class="hero-detail">
<h2 class="hero-title">{hero.title}</h2>
<p class="hero-desc">{hero.description}</p>
<p class="hero-meta">
{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}
</p>
</div>
</div>
<footer class="hero-foot">
<div class="hero-foot-left">
<span class="hero-foot-stat">
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
</span>
{heroAttendees.length > 0 && (
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
)}
</div>
<form method="POST" class="hero-foot-right">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={hero.slug} />
{heroMyRsvp === '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="yes" class="hero-cta">Save your seat →</button>
)}
</form>
</footer>
<!-- Bundled coming-up sub-cards (no RSVP) -->
{comingUp.length > 0 && (
<ul class="coming-up-grid">
{comingUp.map(ev => (
<li class="coming-up-card">
<div class="cu-date">
<span class="cu-day">{dayNum(ev.starts_at)}</span>
<span class="cu-month">{monthShort(ev.starts_at)}</span>
</div>
<div class="cu-body">
<h3 class="cu-title">{ev.title}</h3>
<p class="cu-meta">{[ev.duration_label, ev.audience, eventKindLabel(ev.kind)].filter(Boolean).join(' · ')}</p>
</div>
</li>
))}
</ul>
)}
<a href="/events" class="section-link section-link--ink hero-see-all">See all events</a>
</section> </section>
) : (
<section class="cascade events-card events-card--empty"> <!-- 'Also coming up' strip lands in step 6 — temporarily empty -->
<p class="events-empty-line">Nothing scheduled yet — when we have something, you'll be the first to know.</p>
<a href="/events" class="section-link section-link--ink">See all events</a>
</section>
)}
<!-- ── Latest from Fenja: 2-box article + poll, plus 2 below ── --> <!-- ── Latest from Fenja: 2-box article + poll, plus 2 below ── -->
{featured && ( {featured && (