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 committed by padreug
commit e174048052
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 calendarEvent 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>