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:
parent
096cdb00b6
commit
29fe1b7c92
2 changed files with 348 additions and 71 deletions
337
src/components/EventHeroCard.astro
Normal file
337
src/components/EventHeroCard.astro
Normal 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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import Avatar from '../components/Avatar.astro';
|
||||
import AvatarPile from '../components/AvatarPile.astro';
|
||||
import EventHeroCard from '../components/EventHeroCard.astro';
|
||||
import {
|
||||
getUpcomingEvents, getEventBySlug, getEventAttendees,
|
||||
getUserRsvp, setEventRsvp, recordActivity,
|
||||
|
|
@ -142,78 +142,18 @@ const members = getAllCabMembers();
|
|||
)}
|
||||
</section>
|
||||
|
||||
<!-- ── Events (--ink card) ──────────────────────────────────── -->
|
||||
{hero ? (
|
||||
<section class="cascade events-card" aria-label="Events">
|
||||
<!-- ── Hero event card (--ink) ──────────────────────────────── -->
|
||||
<section class="cascade hero-slot" aria-label="Next gathering">
|
||||
<EventHeroCard
|
||||
event={hero}
|
||||
attendees={heroAttendees}
|
||||
confirmedCount={heroConfirmedCount}
|
||||
myRsvp={heroMyRsvp}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Hero — more air, vertically aligned columns -->
|
||||
<div class="hero-body">
|
||||
<div class="hero-date">
|
||||
<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>
|
||||
<!-- 'Also coming up' strip lands in step 6 — temporarily empty -->
|
||||
|
||||
<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 class="cascade events-card events-card--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 ── -->
|
||||
{featured && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue