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 f01d5aa581
4 changed files with 111 additions and 69 deletions

View file

@ -12,6 +12,10 @@ import type { Activity } from '../types/activity'
const props = defineProps<{ const props = defineProps<{
activity: Activity activity: Activity
/** 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<{ 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" class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', activity)" @click="emit('click', activity)"
> >
<!-- Image with overlaid badges. Cards without an image skip the <!-- Image with overlaid badges. Cards without an image (or in
hero area entirely and surface their badges inline at the top compact mode) skip the hero area entirely and surface their
of the content block the solid-color placeholder + calendar badges inline at the top of the content block the solid-
glyph wasn't communicating anything the title + details don't color placeholder + calendar glyph wasn't communicating
already. --> anything the title + details don't already. -->
<div v-if="activity.image" class="relative aspect-[16/9] overflow-hidden"> <div v-if="activity.image && !compact" class="relative aspect-[16/9] overflow-hidden">
<img <img
:src="activity.image" :src="activity.image"
:alt="activity.title" :alt="activity.title"
@ -135,18 +139,21 @@ const isPast = computed(() => {
</Badge> </Badge>
</div> </div>
<CardContent class="p-4 flex-1 flex flex-col gap-2"> <CardContent
<!-- Inline badge row (no-image variant). Same badges as the :class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
image-overlay set, just stacked horizontally at the top of >
the content area. --> <!-- Inline badge row (no-image variant + compact variant). Same
<div v-if="!activity.image" class="flex flex-wrap items-center gap-1.5"> 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="!activity.image || compact" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs"> <Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </Badge>
<Badge v-if="priceDisplay" class="text-xs"> <Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </Badge>
<Badge v-if="activity.isMine" variant="outline" class="text-xs gap-1"> <Badge v-if="activity.isMine && !compact" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" /> <User class="w-3 h-3" />
Yours Yours
</Badge> </Badge>
@ -167,26 +174,34 @@ const isPast = computed(() => {
</Badge> </Badge>
</div> </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"> <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',
]"
>
{{ activity.title }} {{ activity.title }}
</h3> </h3>
<BookmarkButton <BookmarkButton
v-if="!compact"
:pubkey="activity.organizer.pubkey" :pubkey="activity.organizer.pubkey"
:d-tag="activity.id" :d-tag="activity.id"
/> />
</div> </div>
<!-- Summary --> <!-- Summary (hidden in compact mode) -->
<p <p
v-if="activity.summary" v-if="activity.summary && !compact"
class="text-sm text-muted-foreground line-clamp-2" class="text-sm text-muted-foreground line-clamp-2"
> >
{{ activity.summary }} {{ activity.summary }}
</p> </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 --> <!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground"> <div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" /> <Calendar class="w-3.5 h-3.5 shrink-0" />

View file

@ -7,6 +7,10 @@ import type { Activity } from '../types/activity'
defineProps<{ defineProps<{
activities: Activity[] activities: Activity[]
isLoading?: boolean 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<{ const emit = defineEmits<{
@ -47,12 +51,17 @@ const { t } = useI18n()
</p> </p>
</div> </div>
<!-- Activity grid --> <!-- Activity grid compact mode collapses to a single column of
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 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'"
>
<ActivityCard <ActivityCard
v-for="activity in activities" v-for="activity in activities"
:key="activity.nostrEventId" :key="activity.nostrEventId"
:activity="activity" :activity="activity"
:compact="compact"
@click="emit('select', activity)" @click="emit('select', activity)"
/> />
</div> </div>

View file

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

View file

@ -170,36 +170,14 @@ function goToMyTickets() {
<template> <template>
<div class="container mx-auto py-6 px-4 max-w-3xl"> <div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Top bar --> <!-- Top bar back-link only. Edit moves into the title row as a
<div class="flex items-center justify-between mb-4"> 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"> <Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" /> <ArrowLeft class="w-4 h-4" />
Back Back
</Button> </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> </div>
<!-- Loading --> <!-- Loading -->
@ -231,7 +209,17 @@ function goToMyTickets() {
<!-- Title + bookmark + captions --> <!-- Title + bookmark + captions -->
<div> <div>
<div class="flex flex-wrap items-start gap-2 mb-2"> <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="activity.isMine"
variant="secondary"
class="shrink-0"
>
Yours
</Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </Badge>
<Badge <Badge
@ -241,13 +229,6 @@ function goToMyTickets() {
> >
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </Badge>
<Badge
v-if="activity.isMine"
variant="outline"
class="shrink-0"
>
Yours
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag"> <div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge> <Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div> </div>
@ -256,12 +237,27 @@ function goToMyTickets() {
<h1 class="text-2xl sm:text-3xl font-bold text-foreground"> <h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }} {{ activity.title }}
</h1> </h1>
<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('activities.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 <BookmarkButton
v-else
:pubkey="activity.organizer.pubkey" :pubkey="activity.organizer.pubkey"
:d-tag="activity.id" :d-tag="activity.id"
class="shrink-0 mt-1"
/> />
</div> </div>
</div>
<p v-if="activity.summary" class="text-muted-foreground mt-2"> <p v-if="activity.summary" class="text-muted-foreground mt-2">
{{ activity.summary }} {{ activity.summary }}
</p> </p>
@ -289,24 +285,40 @@ function goToMyTickets() {
<p class="whitespace-pre-wrap">{{ activity.description }}</p> <p class="whitespace-pre-wrap">{{ activity.description }}</p>
</div> </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 activity's actual kind <!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the (31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every activity, leaving RSVPs button would default to time-based for every activity, leaving RSVPs
on date-based activities pointing at a non-existent event coord. --> on date-based activities pointing at a non-existent event coord. -->
<RSVPButton <RSVPButton
v-if="!ownedLnbitsEvent"
:pubkey="activity.organizer.pubkey" :pubkey="activity.organizer.pubkey"
:d-tag="activity.id" :d-tag="activity.id"
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" :kind="activity.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('activities.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- Tickets gated on the activity carrying ticketInfo (set <!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom by the calendarActivity converter from the AIO custom
tickets_* tags on the published event). When the user tickets_* tags on the published event). Skipped for the
already owns tickets, the "you have N tickets / view" host entirely they have the Scan CTA above and don't
card is promoted (filled primary CTA) and the buy CTA need a Buy CTA for their own event. -->
is demoted (outline). --> <div v-if="activity.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
<div v-if="activity.ticketInfo" class="space-y-3">
<div <div
v-if="ownedPaidCount > 0" v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3" class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"