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:
parent
85419ea5fa
commit
e174048052
4 changed files with 111 additions and 69 deletions
|
|
@ -12,6 +12,10 @@ import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event: Event
|
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<{
|
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', event)"
|
@click="emit('click', event)"
|
||||||
>
|
>
|
||||||
<!-- 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="event.image" class="relative aspect-[16/9] overflow-hidden">
|
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
:src="event.image"
|
:src="event.image"
|
||||||
:alt="event.title"
|
:alt="event.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="!event.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="!event.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="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" />
|
<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',
|
||||||
|
]"
|
||||||
|
>
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
|
v-if="!compact"
|
||||||
:pubkey="event.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="event.id"
|
:d-tag="event.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- Summary (hidden in compact mode) -->
|
||||||
<p
|
<p
|
||||||
v-if="event.summary"
|
v-if="event.summary && !compact"
|
||||||
class="text-sm text-muted-foreground line-clamp-2"
|
class="text-sm text-muted-foreground line-clamp-2"
|
||||||
>
|
>
|
||||||
{{ event.summary }}
|
{{ event.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" />
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import type { Event } from '../types/event'
|
||||||
defineProps<{
|
defineProps<{
|
||||||
events: Event[]
|
events: Event[]
|
||||||
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>
|
||||||
|
|
||||||
<!-- Event grid -->
|
<!-- Event 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'"
|
||||||
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
v-for="event in events"
|
v-for="event in events"
|
||||||
:key="event.nostrEventId"
|
:key="event.nostrEventId"
|
||||||
:event="event"
|
:event="event"
|
||||||
|
:compact="compact"
|
||||||
@click="emit('select', event)"
|
@click="emit('select', event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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="event.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() {
|
||||||
>
|
>
|
||||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
|
||||||
v-if="event.isMine"
|
|
||||||
variant="outline"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
|
||||||
Yours
|
|
||||||
</Badge>
|
|
||||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
<div v-for="tag in event.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">
|
||||||
{{ event.title }}
|
{{ event.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('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
|
<BookmarkButton
|
||||||
|
v-else
|
||||||
:pubkey="event.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="event.id"
|
:d-tag="event.id"
|
||||||
class="shrink-0 mt-1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
||||||
{{ event.summary }}
|
{{ event.summary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -289,24 +285,40 @@ function goToMyTickets() {
|
||||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
<p class="whitespace-pre-wrap">{{ event.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 event's actual kind
|
<!-- 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
|
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||||
button would default to time-based for every event, leaving RSVPs
|
button would default to time-based for every event, leaving RSVPs
|
||||||
on date-based events pointing at a non-existent event coord. -->
|
on date-based events pointing at a non-existent event coord. -->
|
||||||
<RSVPButton
|
<RSVPButton
|
||||||
|
v-if="!ownedLnbitsEvent"
|
||||||
:pubkey="event.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="event.id"
|
:d-tag="event.id"
|
||||||
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
: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
|
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||||
by the calendar→Event converter from the AIO custom
|
by the calendar→Event 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="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||||
<div v-if="event.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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<!-- 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
|
<EventList
|
||||||
:events="events"
|
:events="events"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
|
:compact="onlyHosting"
|
||||||
@select="handleSelectEvent"
|
@select="handleSelectEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue