feat(activities): organizer ticket scanner over Nostr transport #73

Merged
padreug merged 4 commits from ticket-scanner-nostr-webapp into dev 2026-05-24 16:51:13 +00:00
6 changed files with 43 additions and 6 deletions
Showing only changes of commit 5ebf0582e0 - Show all commits

feat(activities): "Hosting" filter chip on the activities feed

Companion to the "My tickets" chip from #71. Where "My tickets"
narrows the feed to events you're attending, "Hosting" narrows it
to events you're organizing — reading `activity.isMine` which
useActivities.tagOwnership() already populates from organizer
pubkey match + own LNbits drafts.

Naming rationale: "My events" would have been ambiguous with
favorited / bookmarked. "Hosting" is short, role-oriented, and
pairs as the natural counterpart to "My tickets" (attending vs.
organizing). Spanish/French translations lean on the verb form
("Organizo" / "J'organise") since those languages don't have a
clean noun equivalent.

- useActivityFilters: onlyHosting flag, toggleHosting action,
  resetFilters clears it, hasActiveFilters lights up.
- applyFilters filters by `a.isMine === true` when the flag is
  on. Composes with category / temporal / "My tickets" via the
  same intersection chain.
- ActivitiesPage: chip rendered alongside "My tickets" with the
  Megaphone icon (lucide). Hidden when logged out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Padreug 2026-05-24 17:39:31 +02:00

View file

@ -58,6 +58,7 @@ const messages: LocaleMessages = {
thisWeek: 'This Week', thisWeek: 'This Week',
thisMonth: 'This Month', thisMonth: 'This Month',
myTickets: 'My tickets', myTickets: 'My tickets',
hosting: 'Hosting',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',

View file

@ -58,6 +58,7 @@ const messages: LocaleMessages = {
thisWeek: 'Esta semana', thisWeek: 'Esta semana',
thisMonth: 'Este mes', thisMonth: 'Este mes',
myTickets: 'Mis boletos', myTickets: 'Mis boletos',
hosting: 'Organizo',
}, },
categories: { categories: {
concert: 'Concierto', concert: 'Concierto',

View file

@ -58,6 +58,7 @@ const messages: LocaleMessages = {
thisWeek: 'Cette semaine', thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci', thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets', myTickets: 'Mes billets',
hosting: 'J\'organise',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',

View file

@ -59,6 +59,7 @@ export interface LocaleMessages {
thisWeek: string thisWeek: string
thisMonth: string thisMonth: string
myTickets: string myTickets: string
hosting: string
} }
categories: Record<string, string> categories: Record<string, string>
detail: { detail: {

View file

@ -22,6 +22,13 @@ export function useActivityFilters() {
* (this composable stays free of ticket fetching). * (this composable stays free of ticket fetching).
*/ */
const onlyOwnedTickets = ref(false) 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)
const filters = computed<ActivityFilters>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -54,6 +61,13 @@ export function useActivityFilters() {
) )
} }
// Hosting filter — activities the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) {
result = result.filter(a => a.isMine === true)
}
return result return result
} }
@ -89,17 +103,23 @@ export function useActivityFilters() {
selectedCategories.value = [] selectedCategories.value = []
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false onlyOwnedTickets.value = false
onlyHosting.value = false
} }
function toggleOwnedTickets() { function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value onlyOwnedTickets.value = !onlyOwnedTickets.value
} }
function toggleHosting() {
onlyHosting.value = !onlyHosting.value
}
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
temporal.value !== 'all' || temporal.value !== 'all' ||
selectedCategories.value.length > 0 || selectedCategories.value.length > 0 ||
selectedDate.value !== undefined || selectedDate.value !== undefined ||
onlyOwnedTickets.value onlyOwnedTickets.value ||
onlyHosting.value
) )
return { return {
@ -108,6 +128,7 @@ export function useActivityFilters() {
selectedCategories, selectedCategories,
selectedDate, selectedDate,
onlyOwnedTickets, onlyOwnedTickets,
onlyHosting,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -118,6 +139,7 @@ export function useActivityFilters() {
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,
toggleHosting,
resetFilters, resetFilters,
} }
} }

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next' import { SlidersHorizontal, ChevronDown, Ticket, Megaphone } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities' import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
@ -30,11 +30,13 @@ const {
hasActiveFilters, hasActiveFilters,
selectedDate, selectedDate,
onlyOwnedTickets, onlyOwnedTickets,
onlyHosting,
selectDate, selectDate,
setTemporal, setTemporal,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,
toggleHosting,
resetFilters, resetFilters,
subscribe, subscribe,
} = useActivities() } = useActivities()
@ -79,10 +81,10 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> <TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div> </div>
<!-- "My tickets" filter chip narrows the feed to activities <!-- Role filter chips narrow the feed to activities the user
the user holds at least one paid ticket for. Hidden when has skin in. Hidden when logged out (nothing to filter on).
logged out (no tickets to filter on). --> "My tickets" = attending; "Hosting" = organizing. -->
<div v-if="isAuthenticated" class="mb-4"> <div v-if="isAuthenticated" class="mb-4 flex flex-wrap gap-2">
<Button <Button
:variant="onlyOwnedTickets ? 'default' : 'outline'" :variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm" size="sm"
@ -92,6 +94,15 @@ function handleSelectActivity(activity: Activity) {
<Ticket class="w-3.5 h-3.5" /> <Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }} {{ t('activities.filters.myTickets', 'My tickets') }}
</Button> </Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</div> </div>
<!-- Category filters (collapsible) --> <!-- Category filters (collapsible) -->