Compare commits

...

10 commits

Author SHA1 Message Date
67a070e9b3 fix(activities): relabel "Get invoice" → "Proceed" on PurchaseTicket
The lightning rail's CTA in PurchaseTicketDialog now reads
"Proceed" (or "Proceed (N tickets)" for multi-quantity) instead
of "Get invoice". Matches the language used on the fiat rails
("Continue to Stripe checkout" etc.) and reads as a generic
forward action regardless of which payment path the user picks.
2026-06-05 09:47:59 +02:00
2e96d60f8f fix(activities): drop duplicate empty-state line on ActivityList
The empty state rendered "No activities found" twice — once as the
heading and once as the description below, because
`activities.noActivities` and `activities.search.noResults`
translate to the same string in every locale. Drop the description
paragraph (and its mb-1 spacer on the heading).
2026-06-04 23:43:30 +02:00
9753c4aea4 feat(activities): fuzzy search on the Tickets roster
Adds a search box above the roster list that fuzzy-matches the
holder name and ticket id via the shared useFuzzySearch (Fuse.js)
composable. Empty query keeps the unregistered-first sort intact;
typing reorders by relevance. The empty-state message now
distinguishes "no tickets sold yet" from "no rows matched the
current query" so a busy roster + a typo doesn't look like
backend trouble.
2026-06-04 23:41:57 +02:00
e05f276308 feat(activities): manual ticket registration from the roster tab
The "Scanned" tab becomes "Tickets" and now lists the full event
roster (sold tickets), not just the registered subset. Unregistered
rows lead the list with a Register button so the host can manually
mark someone present without a QR scan — e.g. lost phone, known in
person, or alternate proof of identity.

useTicketScanner gains registerManually(ticketId), which calls the
same PUT /tickets/register/{id} the scanner uses (so it inherits
the event-ownership gate and the unpaid/already-registered backend
checks), then refreshes stats. It skips the scanner pause + full-
screen banner since the operator initiated the action from the
list, and mirrors the session-local dedup so a subsequent QR scan
on the same ticket reports "Already scanned" instead of a duplicate
register round-trip.

The header now reads "registered / total · N to go" so the host
sees roster progress at a glance; failures from the manual register
surface as a sonner toast and the row reverts.
2026-06-04 23:40:02 +02:00
e3f665ceea revert: move scan counts back above the tabs + fix tab centering
Reverts 1aeea23 and folds in the actual fix the relocation was
chasing: the Scanner / Scanned tab labels were rendering with
their icons and text mis-aligned because TabsTrigger wraps its
slot in an inline `<span class="truncate">`. A `gap-1.5` on
TabsTrigger never reached the icon/label children. Wrap each
trigger's content in an `inline-flex items-center gap-1.5` span
so the icon and label share a real flex container.
2026-06-04 23:34:42 +02:00
1aeea23296 feat(activities): move scan counts below the camera
The Scanned / Sold / Remaining strip moves out of the page header
to below the Tabs block. The camera (or scanned list, depending on
the active tab) stays prominent at the top; the counts read as a
summary footer instead of competing with the title for attention.
The stats-error notice follows the counts strip so the warning
stays adjacent to the values it affects.
2026-06-04 23:29:53 +02:00
0382f02b28 feat(activities): refine activity card for pending/rejected + compact
- Wash out pending/rejected events with opacity-50 + grayscale on a
  wrapper div so the operator sees at a glance the event isn't live,
  not just the small badge.
- Pull the status badge OUT of the wash-out wrapper and absolute-
  position it on Card root (bottom-2 left-2, z-10) so it stays in
  full color above the dim card. Both pending and rejected use the
  destructive token — the label text differentiates the two states.
  Bottom-left so it doesn't collide with the category chip on full
  cards or the thumbnail on compact ones.
- Compact rows in the Hosting view now show a small left-aligned
  thumbnail (w-20 h-20, self-center, ml-3, rounded-md) when the
  event carries an image — host can still recognize each event at a
  glance without paying the visual weight of a full hero.
- Card root becomes `relative overflow-hidden`; the wrapper div
  owns the conditional flex-row (compact) / flex-col (default)
  layout and the opacity/grayscale toggling.
2026-06-04 23:25:35 +02:00
0823b0f076 feat(activities): My tickets toggle on the calendar view
Adds a small filter chip above the month grid that, when on, limits
the calendar to events the signed-in user holds at least one paid
ticket for (intersecting ownedActivityIds from useOwnedTickets).
Hidden when logged out — nothing to own. Left-aligned so it
doesn't collide with the fixed top-right hamburger menu.

State is local to the page on purpose: narrowing the calendar
shouldn't also narrow the feed when the user navigates back.
2026-06-04 22:44:39 +02:00
f01d5aa581 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.
2026-06-04 22:41:35 +02:00
df7ab30dc5 fix(activities): share filter refs across useActivities consumers
useActivityFilters allocated a fresh set of refs on every call, so
when activities-app/App.vue (Hosting bottom-nav tab) and
ActivitiesPage.vue each invoked useActivities(), they got
independent onlyHosting/temporal/etc state. Tapping Hosting toggled
the App.vue ref; the page never saw the change. Hoist the filter
refs to module scope so every consumer shares the same instance.
2026-06-04 22:37:03 +02:00
9 changed files with 376 additions and 150 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<{
@ -58,19 +62,46 @@ const isPast = computed(() => {
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now() return end.getTime() < Date.now()
}) })
// Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed(
() => !!props.activity.lnbitsStatus && props.activity.lnbitsStatus !== 'approved',
)
</script> </script>
<template> <template>
<Card <Card
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col" class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
@click="emit('click', activity)" @click="emit('click', activity)"
> >
<!-- Image with overlaid badges. Cards without an image skip the <!-- Wash-out wrapper. The pending/rejected status badge below sits
hero area entirely and surface their badges inline at the top OUTSIDE this wrapper so it stays in full color and reads
of the content block the solid-color placeholder + calendar clearly even when the card is dimmed + desaturated. -->
glyph wasn't communicating anything the title + details don't <div
already. --> class="transition-opacity duration-200"
<div v-if="activity.image" class="relative aspect-[16/9] overflow-hidden"> :class="[
compact ? 'flex flex-row' : 'flex flex-col',
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
]"
>
<!-- Compact thumbnail small square preview on the left of the
row when the event carries an image. `self-center` keeps it
vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. -->
<img
v-if="compact && activity.image"
:src="activity.image"
:alt="activity.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
<!-- 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="activity.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<img <img
:src="activity.image" :src="activity.image"
:alt="activity.title" :alt="activity.title"
@ -106,27 +137,13 @@ const isPast = computed(() => {
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </Badge>
<!-- Pending/rejected overlay for the creator's own non-approved <!-- Past badge shown when the activity has already ended. The
drafts. Only present when the activity originated from a pending/rejected status badge that used to share this slot
local LNbits event (Nostr-sourced activities have no is now an absolute overlay on Card root, above the wash-out,
lnbitsStatus). --> so we still suppress Past when isNonApproved (the status
badge is more actionable in that case). -->
<Badge <Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'" v-if="isPast && !isNonApproved"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="absolute bottom-2 left-2 text-xs capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<!-- Past badge shown when the activity has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline" variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur" class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
@ -135,30 +152,26 @@ 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>
<Badge <Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'" v-if="isPast && !isNonApproved"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="text-xs capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline" variant="outline"
class="text-xs gap-1" class="text-xs gap-1"
> >
@ -167,26 +180,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" />
@ -236,5 +257,22 @@ const isPast = computed(() => {
</div> </div>
</div> </div>
</CardContent> </CardContent>
</div>
<!-- Status badge absolutely positioned on Card root so it sits
ABOVE the wash-out wrapper and keeps its full color.
Pending + rejected both lean on the destructive token so the
non-approved state reads as "needs attention" in every theme;
the label text differentiates the two specific states.
Bottom-right with a slight downward spill so it anchors
visually without competing with the category chip in the
badge row (full cards) or the thumbnail (compact cards). -->
<Badge
v-if="isNonApproved"
variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
</Card> </Card>
</template> </template>

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<{
@ -39,20 +43,24 @@ const { t } = useI18n()
class="flex flex-col items-center justify-center py-16 text-center" class="flex flex-col items-center justify-center py-16 text-center"
> >
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" /> <CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<h3 class="text-lg font-medium text-foreground mb-1"> <h3 class="text-lg font-medium text-foreground">
{{ t('activities.noActivities') }} {{ t('activities.noActivities') }}
</h3> </h3>
<p class="text-sm text-muted-foreground">
{{ t('activities.search.noResults') }}
</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. The
compact gap is bumped a notch so the status badge spilling
past the card's bottom edge has room to sit between cards. -->
<div
v-else
:class="compact ? 'flex flex-col gap-4' : '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

@ -443,7 +443,7 @@ onUnmounted(() => {
</template> </template>
<template v-else> <template v-else>
<Zap class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }} {{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template> </template>
</Button> </Button>
</div> </div>

View file

@ -8,35 +8,22 @@ import type { ActivityCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters' import type { TemporalFilter, ActivityFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters' import { DEFAULT_FILTERS } from '../types/filters'
// Filter state is hoisted to module scope so every `useActivities()` /
// `useActivityFilters()` call shares the same refs. The bottom-nav
// Hosting tab in activities-app/App.vue and the feed view in
// ActivitiesPage.vue both rely on this — without a shared instance,
// tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false)
const showPast = ref(false)
/** /**
* Composable for managing activity filter state and applying filters reactively. * Composable for managing activity filter state and applying filters reactively.
*/ */
export function useActivityFilters() { export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to activities the current user
* holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to activities the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine`
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), activities that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this picking a
* specific past date shows that day's activities regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,

View file

@ -188,6 +188,38 @@ export function useTicketScanner(activityId: Ref<string>) {
isPaused.value = false isPaused.value = false
} }
/**
* Mark a ticket as registered without going through the camera
* used when the host knows the attendee in person or accepts an
* alternate proof of identity. Same backend endpoint as a scan
* (so it also gates on event ownership and rejects unpaid /
* already-registered tickets), but skips the scanner pause +
* full-screen banner since the operator initiated the action
* from the roster directly. Refreshes stats on success.
*/
async function registerManually(
ticketId: string,
): Promise<{ ok: boolean; error?: string }> {
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) return { ok: false, error: 'No wallet admin key available' }
try {
await ticketApi.registerTicket(ticketId, adminKey)
// Mirror the session-local dedup the scan path uses so a
// subsequent QR scan of the same ticket reports "Already
// scanned" instead of round-tripping a duplicate register.
if (!scanned.value.some(r => r.ticketId === ticketId)) {
scanned.value = [
{ ticketId, name: null, registeredAt: new Date().toISOString() },
...scanned.value,
]
}
await refreshStats()
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
function clearScanned() { function clearScanned() {
scanned.value = [] scanned.value = []
lastScan.value = null lastScan.value = null
@ -210,5 +242,6 @@ export function useTicketScanner(activityId: Ref<string>) {
onDecode, onDecode,
resume, resume,
clearScanned, clearScanned,
registerManually,
} }
} }

View file

@ -1,12 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Ticket } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useActivities } from '../composables/useActivities' import { useActivities } from '../composables/useActivities'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { useAuth } from '@/composables/useAuthService'
import ActivityCalendarView from '../components/ActivityCalendarView.vue' import ActivityCalendarView from '../components/ActivityCalendarView.vue'
import type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const { allActivities, subscribe } = useActivities() const { allActivities, subscribe } = useActivities()
const { ownedActivityIds } = useOwnedTickets()
const { isAuthenticated } = useAuth()
// Per-page toggle, intentionally not wired to the feed's
// onlyOwnedTickets filter narrowing the calendar shouldn't also
// narrow the feed the user navigates back to.
const onlyMine = ref(false)
const visibleActivities = computed<Activity[]>(() => {
if (!onlyMine.value) return allActivities.value
const owned = ownedActivityIds.value
return allActivities.value.filter(a => owned.has(a.id))
})
onMounted(() => { onMounted(() => {
subscribe() subscribe()
@ -19,8 +38,23 @@ function handleSelectActivity(activity: Activity) {
<template> <template>
<div class="container mx-auto px-4 py-6 max-w-lg"> <div class="container mx-auto px-4 py-6 max-w-lg">
<!-- Filter chip: narrows the calendar to events the user has
paid tickets for. Hidden when logged out nothing to own.
Left-aligned so it doesn't collide with the fixed top-right
hamburger menu. -->
<div v-if="isAuthenticated" class="mb-3 flex">
<Button
:variant="onlyMine ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="onlyMine = !onlyMine"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
</div>
<ActivityCalendarView <ActivityCalendarView
:activities="allActivities" :activities="visibleActivities"
@select-activity="handleSelectActivity" @select-activity="handleSelectActivity"
/> />
</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,11 +237,26 @@ 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>
<BookmarkButton <div class="flex items-center gap-1 shrink-0 mt-1">
:pubkey="activity.organizer.pubkey" <Button
:d-tag="activity.id" v-if="ownedLnbitsEvent"
class="shrink-0 mt-1" 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
v-else
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</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 }}
@ -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"

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { import {
ArrowLeft, ArrowLeft,
CheckCircle2, CheckCircle2,
@ -9,15 +10,20 @@ import {
Ticket, Ticket,
ScanLine, ScanLine,
RefreshCw, RefreshCw,
UserCheck,
Search,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue' import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner' import { useTicketScanner } from '../composables/useTicketScanner'
import type { EventTicket } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail' import { useActivityDetail } from '../composables/useActivityDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -35,8 +41,14 @@ const {
refreshStats, refreshStats,
onDecode, onDecode,
resume, resume,
registerManually,
} = useTicketScanner(activityId) } = useTicketScanner(activityId)
// Tracks tickets currently mid-register (manual button click), so each
// row can render a per-row spinner without blocking the rest of the
// list. A Set keeps add/remove O(1).
const pendingRegister = ref<Set<string>>(new Set())
const scannerOpen = ref(true) const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner') const activeTab = ref<'scanner' | 'list'>('scanner')
@ -64,11 +76,55 @@ const remainingCount = computed(() => {
return Math.max(0, soldCount.value - registeredCount.value) return Math.max(0, soldCount.value - registeredCount.value)
}) })
// Registered tickets only what the "Scanned" tab shows. // Full ticket roster, sorted so unregistered (actionable) rows lead
const registeredTickets = computed( // and registered rows follow most-recent-first. Powers the Tickets
() => eventStats.value?.tickets.filter(t => t.registered) ?? [], // tab where the host can manually register attendees who can prove
// identity but can't present a scannable QR.
const allTickets = computed<EventTicket[]>(() => {
const list = eventStats.value?.tickets ?? []
return [...list].sort((a, b) => {
if (a.registered !== b.registered) return a.registered ? 1 : -1
if (a.registered && b.registered) {
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
}
return 0
})
})
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
const unregisteredCount = computed(
() => allTickets.value.filter(t => !t.registered).length,
) )
// Fuzzy match on holder name + ticket id. When the search box is
// empty, Fuse returns the list in its incoming order so our
// unregistered-first sort is preserved.
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
allTickets,
{
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'id', weight: 0.3 },
],
threshold: 0.3,
ignoreLocation: true,
},
matchAllWhenSearchEmpty: true,
},
)
async function handleManualRegister(ticket: EventTicket) {
pendingRegister.value.add(ticket.id)
const res = await registerManually(ticket.id)
pendingRegister.value.delete(ticket.id)
if (res.ok) {
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
} else {
toast.error(res.error || 'Failed to register')
}
}
function handleResult(qrText: string) { function handleResult(qrText: string) {
// Don't pause the scanner useQRScanner's `maxScansPerSecond: 5` // Don't pause the scanner useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same // already throttles, and useTicketScanner.onDecode dedups the same
@ -156,13 +212,21 @@ function fmtTime(iso: string) {
<Tabs v-model="activeTab" class="w-full"> <Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4"> <TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="scanner" class="gap-1.5"> <!-- Icon + label wrapped in a real flex container so they
<ScanLine class="w-4 h-4" /> share a gap and items-center alignment. TabsTrigger's
Scanner internal slot lives in an inline span, so a `gap-1.5`
on the trigger itself never reaches these two children. -->
<TabsTrigger value="scanner">
<span class="inline-flex items-center justify-center gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="list" class="gap-1.5"> <TabsTrigger value="list">
<Ticket class="w-4 h-4" /> <span class="inline-flex items-center justify-center gap-1.5">
Scanned ({{ registeredCount }}) <Ticket class="w-4 h-4" />
Tickets ({{ totalTicketsCount }})
</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -190,39 +254,83 @@ function fmtTime(iso: string) {
<TabsContent value="list" class="mt-0"> <TabsContent value="list" class="mt-0">
<div class="space-y-3"> <div class="space-y-3">
<h2 class="text-sm font-medium text-foreground"> <h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered {{ registeredCount }} / {{ totalTicketsCount }} registered
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
· {{ unregisteredCount }} to go
</span>
</h2> </h2>
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]"> <!-- Fuzzy filter on holder name + ticket id (Fuse.js via
useFuzzySearch). Empty query all rows in their
sort order; typing reordered by relevance. -->
<div v-if="allTickets.length > 0" class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
type="search"
placeholder="Search by name or ticket id…"
class="pl-8 h-9"
/>
</div>
<!-- Unregistered rows lead the list so the operator can act
on the actionable ones first; tap "Register" to mark an
attendee present without a QR (e.g. lost phone, known
in person). Failures surface as a toast; the row reverts. -->
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3"> <ul class="space-y-1.5 pr-3">
<li <li
v-for="record in registeredTickets" v-for="ticket in searchedTickets"
:key="record.id" :key="ticket.id"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm" class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
> >
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Badge <Badge
v-if="record.registeredAt" v-if="ticket.registered && ticket.registeredAt"
variant="secondary" variant="secondary"
class="text-[10px] font-mono px-1.5" class="text-[10px] font-mono px-1.5"
> >
{{ fmtTime(record.registeredAt) }} {{ fmtTime(ticket.registeredAt) }}
</Badge> </Badge>
<span v-if="record.name" class="font-medium text-foreground"> <span v-if="ticket.name" class="font-medium text-foreground">
{{ record.name }} {{ ticket.name }}
</span> </span>
</div> </div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5"> <p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ record.id }} {{ ticket.id }}
</p> </p>
</div> </div>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" /> <CheckCircle2
v-if="ticket.registered"
class="w-4 h-4 text-emerald-500 shrink-0"
/>
<Button
v-else
size="sm"
variant="outline"
class="shrink-0 gap-1"
:disabled="pendingRegister.has(ticket.id)"
@click="handleManualRegister(ticket)"
>
<RefreshCw
v-if="pendingRegister.has(ticket.id)"
class="w-3.5 h-3.5 animate-spin"
/>
<UserCheck v-else class="w-3.5 h-3.5" />
Register
</Button>
</li> </li>
</ul> </ul>
</ScrollArea> </ScrollArea>
<p
v-else-if="allTickets.length === 0"
class="text-sm text-muted-foreground text-center py-12"
>
No tickets sold yet.
</p>
<p v-else class="text-sm text-muted-foreground text-center py-12"> <p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets scanned yet. No tickets match {{ searchQuery }}.
</p> </p>
</div> </div>
</TabsContent> </TabsContent>