Compare commits

..

2 commits

Author SHA1 Message Date
2498fbe518 fix(activities): pause scanner after each decode, require tap to scan next
Without a pause gate the qr-scanner's 5-fps decode loop instantly
fires another scan on whatever QR is still in frame — most
visibly, the ticket that just registered immediately re-fires as
"already scanned this session". The operator at the door never
gets a beat to confirm the result or act on it (let the attendee
in, deny entry, redirect to manual lookup, etc.).

useTicketScanner gains an `isPaused` ref that flips to true the
moment a decode resolves (success, error, or duplicate-session
de-dup) and gates further `onDecode` calls. The camera keeps
streaming so resumption is instant — only the decode handler is
muted.

The page replaces the small "Dismiss" button with a full-width
"Scan next" CTA below the result banner. Same place every time so
the operator's hand can stay in muscle memory; disabled while the
in-flight RPC is still sending. Result banner upgrades to a
slightly larger icon + label so the success/failure is readable
at arm's length over the venue.

`clearScanned` also resets `isPaused` so the operator can recover
from a stuck state via the "Clear list" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:00:21 +02:00
5ebf0582e0 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>
2026-05-24 17:58:12 +02:00
8 changed files with 96 additions and 25 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -22,6 +22,13 @@ export function useActivityFilters() {
* (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)
const filters = computed<ActivityFilters>(() => ({
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
}
@ -89,17 +103,23 @@ export function useActivityFilters() {
selectedCategories.value = []
selectedDate.value = undefined
onlyOwnedTickets.value = false
onlyHosting.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
}
function toggleHosting() {
onlyHosting.value = !onlyHosting.value
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
selectedDate.value !== undefined ||
onlyOwnedTickets.value
onlyOwnedTickets.value ||
onlyHosting.value
)
return {
@ -108,6 +128,7 @@ export function useActivityFilters() {
selectedCategories,
selectedDate,
onlyOwnedTickets,
onlyHosting,
filters,
hasActiveFilters,
@ -118,6 +139,7 @@ export function useActivityFilters() {
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
resetFilters,
}
}

View file

@ -39,6 +39,17 @@ export function useTicketScanner(activityId: Ref<string>) {
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
/**
* Set to `true` immediately after a decode resolves (success or
* failure) and stays true until the operator dismisses or taps
* "Scan next". While paused, further `onDecode` calls are dropped
* the camera keeps streaming for instant resume but the result
* banner sticks so the door can confirm the outcome before
* moving on. Without this, the 5-fps decode loop instantly
* fires "already scanned this session" on the very ticket that
* just succeeded.
*/
const isPaused = ref(false)
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
[],
@ -51,7 +62,7 @@ export function useTicketScanner(activityId: Ref<string>) {
}
async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value) return
if (isProcessing.value || isPaused.value) return
const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return
@ -60,6 +71,7 @@ export function useTicketScanner(activityId: Ref<string>) {
// of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return
}
@ -90,24 +102,32 @@ export function useTicketScanner(activityId: Ref<string>) {
}
} finally {
isProcessing.value = false
// Pause the decode loop regardless of outcome. The operator
// taps "Scan next" to resume; this guarantees they see the
// banner and can correct (let the attendee in, deny entry,
// etc.) before the next QR comes into frame.
isPaused.value = true
}
}
function resume() {
lastScan.value = null
isPaused.value = false
}
function clearScanned() {
scanned.value = []
lastScan.value = null
}
function dismissLastScan() {
lastScan.value = null
isPaused.value = false
}
return {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
dismissLastScan,
}
}

View file

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

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2 } from 'lucide-vue-next'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2, ScanLine } from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -19,11 +19,12 @@ const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
dismissLastScan,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
@ -92,8 +93,10 @@ function fmtTime(iso: string) {
</Button>
</div>
<!-- Last-scan banner single line, dismissable. Keeps the
scanner viewport clean while still surfacing the result. -->
<!-- Last-scan result. Sticks until the operator taps "Scan
next" gives them time to verify the outcome and act on
it (let the attendee in, deny entry, etc.) before the
decode loop picks up the next QR. -->
<div
v-if="lastScan"
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
@ -105,18 +108,18 @@ function fmtTime(iso: string) {
>
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-5 h-5 text-emerald-500 shrink-0 mt-0.5"
class="w-6 h-6 text-emerald-500 shrink-0 mt-0.5"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-5 h-5 text-amber-500 shrink-0 mt-0.5"
class="w-6 h-6 text-amber-500 shrink-0 mt-0.5"
/>
<AlertCircle
v-else
class="w-5 h-5 text-destructive shrink-0 mt-0.5"
class="w-6 h-6 text-destructive shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">
<p class="text-base font-semibold text-foreground">
<template v-if="lastScan.status === 'ok'">
Registered
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
@ -130,18 +133,29 @@ function fmtTime(iso: string) {
{{ lastScan.message || 'Scan failed' }}
</template>
</p>
<p class="text-xs font-mono text-muted-foreground break-all mt-0.5">
<p class="text-xs font-mono text-muted-foreground break-all mt-1">
{{ lastScan.ticketId }}
</p>
</div>
<Button variant="ghost" size="sm" class="shrink-0" @click="dismissLastScan">
Dismiss
</Button>
</div>
<!-- Processing hint -->
<!-- "Scan next" CTA primary action when a result is pending,
so the operator's hand goes to the same place every time
(full-width button below the result). Disabled while the
RPC is still in-flight. -->
<Button
v-if="isPaused"
class="w-full mt-3 gap-1.5"
size="lg"
:disabled="isProcessing"
@click="resume"
>
<ScanLine class="w-4 h-4" />
<span v-if="isProcessing">Sending</span>
<span v-else>Scan next</span>
</Button>
<p
v-if="isProcessing"
v-else-if="isProcessing"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr