From 29fe1b7c92cfd2ca55ea09005d5c75366af36203 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 10:50:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(component):=20EventHeroCard=20=E2=80=94=20?= =?UTF-8?q?the=20indigo=20card=20that=20carries=20/pulse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- src/components/EventHeroCard.astro | 337 +++++++++++++++++++++++++++++ src/pages/pulse.astro | 82 +------ 2 files changed, 348 insertions(+), 71 deletions(-) create mode 100644 src/components/EventHeroCard.astro diff --git a/src/components/EventHeroCard.astro b/src/components/EventHeroCard.astro new file mode 100644 index 0000000..f799f88 --- /dev/null +++ b/src/components/EventHeroCard.astro @@ -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 ? ( +
+ +
+
+ {weekday} + {dayPadded} + {monthShort} · {startTime} +
+ +
+

Next gathering · {eventKindLabel(event.kind).toUpperCase()}

+

{event.title}

+

{event.description}

+

{event.location}

+
+ +
+ {event.duration_label && ( +

{event.duration_label.toUpperCase()}

+ )} + {visibleAttendees.length > 0 && ( +
    + {visibleAttendees.map(u => ( +
  • + {redactName(u.name)} + +
  • + ))} + {overflow > 0 && ( +
  • + +{overflow} more + +
  • + )} +
+ )} +
+
+ +
+

+ {event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED + + CLOSES {closesDay} +

+ +
+ + + {myRsvp === 'yes' ? ( + <> + You're confirmed ✓ + + + ) : ( + <> + + + + )} +
+
+
+) : ( +
+

Nothing scheduled yet — when we have something, you'll be the first to know.

+
+)} + + diff --git a/src/pages/pulse.astro b/src/pages/pulse.astro index b151e53..8571e67 100644 --- a/src/pages/pulse.astro +++ b/src/pages/pulse.astro @@ -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(); )} - - {hero ? ( -
+ +
+ +
- -
-
- {weekday(hero.starts_at)} - {dayNum(hero.starts_at)} - {monthShort(hero.starts_at)} -
+ -
-

{hero.title}

-

{hero.description}

-

- {hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''} -

-
-
- -
-
- - {hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed - - {heroAttendees.length > 0 && ( - - )} -
- -
- - - {heroMyRsvp === 'yes' ? ( - <> - You're confirmed ✓ - - - ) : ( - - )} -
-
- - - {comingUp.length > 0 && ( -
    - {comingUp.map(ev => ( -
  • -
    - {dayNum(ev.starts_at)} - {monthShort(ev.starts_at)} -
    -
    -

    {ev.title}

    -

    {[ev.duration_label, ev.audience, eventKindLabel(ev.kind)].filter(Boolean).join(' · ')}

    -
    -
  • - ))} -
- )} - - See all events - -
- ) : ( -
-

Nothing scheduled yet — when we have something, you'll be the first to know.

- See all events -
- )} {featured && (