feat(activities): tailor Hosting tab + host detail view for operators

Hosting feed (ActivitiesPage when onlyHosting):
- Hide the date picker strip + calendar shortcut and the entire
  Filters/temporal-pills row; an operator managing their roster
  doesn't need calendar navigation or temporal narrowing.
- Keep the search bar — finding a specific event in a long roster
  still matters.
- Render compact cards via a new `compact` prop on ActivityList +
  ActivityCard: no hero image, single-line title, no summary, no
  bookmark, no "Yours" badge (every card is the operator's own),
  tighter p-3 padding, single-column flex layout.

Host detail view (ActivityDetailPage when ownedLnbitsEvent):
- Drop the top-bar Scan and Edit buttons. Edit moves into the title
  row as a prominent filled-primary icon button right of the title;
  Scan moves into the tickets section.
- Render a full-width "Scan tickets" CTA in place of Buy ticket, and
  hoist it outside the ticketInfo gate so it appears even on hosted
  events that were published without AIO ticket tags.
- Hide BookmarkButton and RSVPButton for the host (favoriting /
  RSVPing your own event are noise affordances).

Detail-page badge row: "Yours" leads the row in the highlighted
secondary variant; category and tags drop to outline so the
ownership signal stands out.
This commit is contained in:
Padreug 2026-06-04 22:41:35 +02:00
commit 4778c34671
4 changed files with 111 additions and 69 deletions

View file

@ -12,6 +12,10 @@ import type { Event } from '../types/event'
const props = defineProps<{
event: Event
/** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */
compact?: boolean
}>()
const emit = defineEmits<{
@ -65,12 +69,12 @@ const isPast = computed(() => {
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', event)"
>
<!-- Image with overlaid badges. Cards without an image skip the
hero area entirely and surface their badges inline at the top
of the content block — the solid-color placeholder + calendar
glyph wasn't communicating anything the title + details don't
already. -->
<div v-if="event.image" class="relative aspect-[16/9] overflow-hidden">
<!-- Image with overlaid badges. Cards without an image (or in
compact mode) skip the hero area entirely and surface their
badges inline at the top of the content block — the solid-
color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. -->
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden">
<img
:src="event.image"
:alt="event.title"
@ -135,18 +139,21 @@ const isPast = computed(() => {
</Badge>
</div>
<CardContent class="p-4 flex-1 flex flex-col gap-2">
<!-- Inline badge row (no-image variant). Same badges as the
image-overlay set, just stacked horizontally at the top of
the content area. -->
<div v-if="!event.image" class="flex flex-wrap items-center gap-1.5">
<CardContent
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
>
<!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div v-if="!event.image || compact" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge v-if="event.isMine" variant="outline" class="text-xs gap-1">
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" />
Yours
</Badge>
@ -167,26 +174,34 @@ const isPast = computed(() => {
</Badge>
</div>
<!-- Title + Bookmark -->
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
own event — bookmarking it would be noise) and clamps the
title to a single line. -->
<div class="flex items-start gap-1">
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
<h3
:class="[
'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
>
{{ event.title }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
<!-- Summary -->
<!-- Summary (hidden in compact mode) -->
<p
v-if="event.summary"
v-if="event.summary && !compact"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ event.summary }}
</p>
<div class="mt-auto space-y-1.5 pt-2">
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />

View file

@ -7,6 +7,10 @@ import type { Event } from '../types/event'
defineProps<{
events: Event[]
isLoading?: boolean
/** Render compact rows instead of full-image cards. Used by the
* Hosting view so an operator can scan their roster of events
* without the visual weight of hero images they already recognize. */
compact?: boolean
}>()
const emit = defineEmits<{
@ -47,12 +51,17 @@ const { t } = useI18n()
</p>
</div>
<!-- Event grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Event grid — compact mode collapses to a single column of
tight rows; default mode is the responsive card grid. -->
<div
v-else
:class="compact ? 'flex flex-col gap-2' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
>
<EventCard
v-for="event in events"
:key="event.nostrEventId"
:event="event"
:compact="compact"
@click="emit('select', event)"
/>
</div>

View file

@ -170,36 +170,14 @@ function goToMyTickets() {
<template>
<div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Top bar -->
<div class="flex items-center justify-between mb-4">
<!-- Top bar — back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
<div class="flex items-center mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openScannerPage"
aria-label="Scan tickets"
>
<ScanLine class="w-4 h-4" />
Scan
</Button>
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openEditDialog"
aria-label="Edit event"
>
<Pencil class="w-4 h-4" />
Edit
</Button>
</div>
</div>
<!-- Loading -->
@ -231,7 +209,17 @@ function goToMyTickets() {
<!-- Title + bookmark + captions -->
<div>
<div class="flex flex-wrap items-start gap-2 mb-2">
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0">
<!-- "Yours" leads the row in the highlighted variant so the
ownership signal stands out against the neutral
category/tag chips that follow. -->
<Badge
v-if="event.isMine"
variant="secondary"
class="shrink-0"
>
Yours
</Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
{{ categoryLabel }}
</Badge>
<Badge
@ -241,13 +229,6 @@ function goToMyTickets() {
>
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<Badge
v-if="event.isMine"
variant="outline"
class="shrink-0"
>
Yours
</Badge>
<div v-for="tag in event.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
@ -256,11 +237,26 @@ function goToMyTickets() {
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ event.title }}
</h1>
<BookmarkButton
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
class="shrink-0 mt-1"
/>
<div class="flex items-center gap-1 shrink-0 mt-1">
<Button
v-if="ownedLnbitsEvent"
variant="default"
size="icon"
class="h-8 w-8"
:aria-label="t('events.detail.editEvent', 'Edit event')"
@click="openEditDialog"
>
<Pencil class="w-4 h-4" />
</Button>
<!-- Hosts don't need to favorite their own event — the
"Yours" badge already marks it, and the bookmark
affordance is meant for discovery, not management. -->
<BookmarkButton
v-else
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
</div>
<p v-if="event.summary" class="text-muted-foreground mt-2">
{{ event.summary }}
@ -289,24 +285,40 @@ function goToMyTickets() {
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<!-- RSVP -->
<!-- RSVP — hidden for the host since RSVPing to your own event
is a noise affordance. -->
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every event, leaving RSVPs
on date-based events pointing at a non-existent event coord. -->
<RSVPButton
v-if="!ownedLnbitsEvent"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags — a host always
gets to scan attempts. Stays available for past events too
so the host can still verify attendance after the fact. -->
<Button
v-if="ownedLnbitsEvent"
class="w-full gap-1.5"
size="lg"
@click="openScannerPage"
>
<ScanLine class="w-4 h-4" />
{{ t('events.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- Tickets — gated on the event carrying ticketInfo (set
by the calendar→Event converter from the AIO custom
tickets_* tags on the published event). When the user
already owns tickets, the "you have N tickets / view"
card is promoted (filled primary CTA) and the buy CTA
is demoted (outline). -->
<div v-if="event.ticketInfo" class="space-y-3">
tickets_* tags on the published event). Skipped for the
host entirely — they have the Scan CTA above and don't
need a Buy CTA for their own event. -->
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
<div
v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"

View file

@ -90,8 +90,10 @@ function openCalendar() {
<!-- Date picker strip + calendar shortcut. The calendar icon used
to be a bottom-nav tab; it now lives on the right of the week
strip so the tabs row stays focused on the primary views. -->
<div class="mb-3 flex items-center gap-2">
strip so the tabs row stays focused on the primary views.
Hidden in the Hosting view — operators don't need calendar
navigation when they're managing their own roster. -->
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
<DatePickerStrip
class="flex-1 min-w-0"
:selected-date="selectedDate"
@ -112,8 +114,9 @@ function openCalendar() {
column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when past-events or any
categories are active) opens a collapsible that hosts the
past-events toggle + category chips below. -->
<Collapsible v-model:open="filtersOpen" class="mb-3">
past-events toggle + category chips below. Hidden in the
Hosting view — the operator's roster doesn't need them. -->
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
<div class="flex items-start gap-3">
<div class="shrink-0 flex flex-col items-center gap-0.5">
<CollapsibleTrigger as-child>
@ -185,10 +188,13 @@ function openCalendar() {
{{ error }}
</div>
<!-- Event feed -->
<!-- Event feed. The Hosting view renders compact rows so the
operator can scan their roster without the visual weight of
hero images they already recognize. -->
<EventList
:events="events"
:is-loading="isLoading"
:compact="onlyHosting"
@select="handleSelectEvent"
/>
</div>